feat: 新增鉴权
This commit is contained in:
@@ -20,3 +20,7 @@ LOG_LEVEL=INFO
|
||||
|
||||
# Prisma 配置,当前答题服务不依赖数据库,但项目保留 Prisma
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/database"
|
||||
|
||||
# better-auth 配置
|
||||
BETTER_AUTH_SECRET=your-better-auth-secret-here
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
@@ -24,3 +24,5 @@ logs
|
||||
!.env.example
|
||||
|
||||
/prisma/generated
|
||||
|
||||
.agents
|
||||
@@ -0,0 +1,74 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const content = `// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
const startTime = Date.now();
|
||||
export const SERVICE_VERSION = "1.1.0";
|
||||
|
||||
export 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())}\`;
|
||||
};
|
||||
|
||||
export const addQaRecord = async (record: {
|
||||
userId: string;
|
||||
question: string;
|
||||
type: string;
|
||||
options: string;
|
||||
answer: string;
|
||||
}) => {
|
||||
await prisma.qaRecord.create({
|
||||
data: {
|
||||
userId: record.userId,
|
||||
question: record.question,
|
||||
type: record.type,
|
||||
options: record.options || null,
|
||||
answer: record.answer || null
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getQaRecords = async (
|
||||
userId: string,
|
||||
options: { page: number; size: number }
|
||||
) => {
|
||||
const { page, size } = options;
|
||||
const skip = (page - 1) * size;
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.qaRecord.count({ where: { userId } }),
|
||||
prisma.qaRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: size,
|
||||
select: { question: true, type: true, options: true, answer: true, createdAt: true }
|
||||
})
|
||||
]);
|
||||
const records = rows.map((r) => ({
|
||||
time: formatLocalDateTime(r.createdAt),
|
||||
timestamp: r.createdAt.toISOString(),
|
||||
question: r.question,
|
||||
type: r.type,
|
||||
options: r.options ?? "",
|
||||
answer: r.answer ?? ""
|
||||
}));
|
||||
return { records, total };
|
||||
};
|
||||
|
||||
export const getRuntimeStats = () => {
|
||||
return {
|
||||
version: SERVICE_VERSION,
|
||||
uptime: (Date.now() - startTime) / 1000,
|
||||
model: serverEnv.openAiModel,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
cache_size: answerCache?.size() ?? 0
|
||||
};
|
||||
};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'server', 'utils', 'runtimeState.ts'), content, 'utf8');
|
||||
console.log('Written successfully');
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- app/components/CopyCom.vue - 通用复制按钮,带图标切换动画 -->
|
||||
<script lang="ts" setup>
|
||||
import { Check, Copy } from "lucide-vue-next";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
|
||||
import type { ButtonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copy } from "@/utils";
|
||||
|
||||
const {
|
||||
text = "",
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
timeout = 2000,
|
||||
disableDefaultCopy = false
|
||||
} = defineProps<{
|
||||
/** 要复制的文本 */
|
||||
text?: string;
|
||||
class?: HTMLAttributes["class"] | undefined;
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
/** 复制成功后恢复的延迟毫秒数,默认 2000 */
|
||||
timeout?: number;
|
||||
/** 禁用内置复制行为,只触发状态变化(外部自行处理复制) */
|
||||
disableDefaultCopy?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [text: string];
|
||||
}>();
|
||||
|
||||
const isCopied = ref(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!disableDefaultCopy) {
|
||||
copy(text);
|
||||
}
|
||||
emit("copy", text);
|
||||
isCopied.value = true;
|
||||
setTimeout(() => {
|
||||
isCopied.value = false;
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
defineExpose({ isCopied });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:class="cn('relative overflow-hidden pl-8', className)"
|
||||
@click.prevent.stop="handleCopy"
|
||||
>
|
||||
<!-- 复制成功图标:未复制时向上移出,复制后滑入 -->
|
||||
<Check
|
||||
class="absolute size-3.5 transition-transform duration-200 left-2.5"
|
||||
:class="isCopied ? 'translate-y-0' : '-translate-y-6'"
|
||||
/>
|
||||
<!-- 默认复制图标:复制后向下移出 -->
|
||||
<Copy
|
||||
class="absolute size-3.5 transition-transform duration-200 left-2.5"
|
||||
:class="isCopied ? 'translate-y-6' : 'translate-y-0'"
|
||||
/>
|
||||
<slot :is-copied="isCopied">
|
||||
<span>复制</span>
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -1,7 +1,10 @@
|
||||
<!-- app/components/home/AnswerForm.vue - 答题提交表单,状态全部来自 Pinia store -->
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { Search } from "lucide-vue-next";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -11,7 +14,13 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +35,10 @@ import { QUESTION_TYPE_OPTIONS, useAnswerStore } from "@/stores/answer";
|
||||
const answerStore = useAnswerStore();
|
||||
const { question, questionType, options, searchLoading } =
|
||||
storeToRefs(answerStore);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
/** session 尚未初始化完成(客户端 plugin 还在请求中) */
|
||||
const sessionChecking = computed(() => !authStore.initialized);
|
||||
const AUTO_TYPE_VALUE = "__auto__";
|
||||
|
||||
/** shadcn Select 不使用空字符串作为 item value,这里只在组件边界做一次映射 */
|
||||
@@ -37,10 +50,47 @@ const selectedQuestionType = computed({
|
||||
}
|
||||
});
|
||||
|
||||
/** 表单提交只触发 store action,组件不直接接触请求服务 */
|
||||
const onSubmit = () => {
|
||||
void answerStore.searchAnswer();
|
||||
};
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
question: z
|
||||
.string({ required_error: "题目不能为空" })
|
||||
.min(1, "题目不能为空"),
|
||||
options: z.string().default("")
|
||||
})
|
||||
);
|
||||
|
||||
const { handleSubmit, setFieldError } = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: { question: question.value, options: options.value }
|
||||
});
|
||||
|
||||
let clearErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** 表单提交先更新 store 再触发 action,组件不直接接触请求服务 */
|
||||
const onSubmit = handleSubmit(
|
||||
(values) => {
|
||||
if (clearErrorTimer) {
|
||||
clearTimeout(clearErrorTimer);
|
||||
clearErrorTimer = null;
|
||||
}
|
||||
question.value = values.question;
|
||||
options.value = values.options ?? "";
|
||||
void answerStore.searchAnswer();
|
||||
},
|
||||
() => {
|
||||
// 校验失败 5s 后自动清除提示
|
||||
if (clearErrorTimer) clearTimeout(clearErrorTimer);
|
||||
clearErrorTimer = setTimeout(() => {
|
||||
setFieldError("question", undefined);
|
||||
clearErrorTimer = null;
|
||||
}, 5000);
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除定时器
|
||||
if (clearErrorTimer) clearTimeout(clearErrorTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,24 +103,34 @@ const onSubmit = () => {
|
||||
</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>
|
||||
<form class="grid gap-4" @submit="onSubmit">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="question"
|
||||
:validate-on-blur="false"
|
||||
:validate-on-change="false"
|
||||
:validate-on-model-update="false"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>题目</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
class="min-h-32 resize-y"
|
||||
placeholder="输入题目内容"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<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>
|
||||
<label class="text-sm font-medium leading-none">题型</label>
|
||||
<Select v-model="selectedQuestionType">
|
||||
<SelectTrigger id="answer-type" class="w-full">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="自动判断" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -85,22 +145,35 @@ const onSubmit = () => {
|
||||
</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>
|
||||
<FormField v-slot="{ componentField }" name="options">
|
||||
<FormItem>
|
||||
<FormLabel>选项</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
class="min-h-24 resize-y"
|
||||
placeholder="A. 选项一 B. 选项二"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button type="submit" :disabled="searchLoading">
|
||||
<!-- 已登录或 session 尚未确认:乐观显示正常按钮(SSR/客户端 DOM 一致,避免水合不一致)-->
|
||||
<!-- 确认未登录后才切换为"去登录",此切换只发生在客户端 plugin 完成后 -->
|
||||
<Button
|
||||
v-if="authStore.isOnline || sessionChecking"
|
||||
type="submit"
|
||||
:disabled="searchLoading"
|
||||
>
|
||||
<Search class="size-4" />
|
||||
<span>{{ searchLoading ? "查询中" : "获取答案" }}</span>
|
||||
</Button>
|
||||
<Button v-else as-child variant="outline">
|
||||
<NuxtLink to="/login">去登录</NuxtLink>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<!-- 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,
|
||||
@@ -10,17 +7,28 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const origin = ref("http://localhost:3000");
|
||||
const copied = ref(false);
|
||||
|
||||
/** 浏览器端读取当前站点地址,避免文档里写死 localhost 或生产域名 */
|
||||
onMounted(() => {
|
||||
origin.value = window.location.origin;
|
||||
});
|
||||
|
||||
/** 与旧 Python 项目的 ocs_config_example.json 保持同样字段 */
|
||||
/** 与旧 Python 项目的 ocs_config_example.json 保持同样字段;登录后自动填入 apiToken */
|
||||
const configText = computed(() => {
|
||||
const data: Record<string, string> = {
|
||||
title: "${title}",
|
||||
type: "${type}",
|
||||
options: "${options}"
|
||||
};
|
||||
|
||||
if (authStore.user?.apiToken) {
|
||||
data.token = authStore.user.apiToken;
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
[
|
||||
{
|
||||
@@ -29,11 +37,7 @@ const configText = computed(() => {
|
||||
url: `${origin.value}/api/search`,
|
||||
method: "get",
|
||||
contentType: "json",
|
||||
data: {
|
||||
title: "${title}",
|
||||
type: "${type}",
|
||||
options: "${options}"
|
||||
},
|
||||
data,
|
||||
handler:
|
||||
"return (res)=> res.code === 1 ? [res.question, res.answer] : [res.msg, undefined]"
|
||||
}
|
||||
@@ -42,15 +46,6 @@ const configText = computed(() => {
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
/** 复制配置片段,失败时不打断主流程 */
|
||||
const copyConfig = async () => {
|
||||
await navigator.clipboard.writeText(configText.value).catch(() => undefined);
|
||||
copied.value = true;
|
||||
window.setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 1200);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,11 +53,7 @@ const copyConfig = async () => {
|
||||
<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>
|
||||
<CopyCom :text="configText" :timeout="1200" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<!-- app/components/layout/AppHeader.vue - 顶部导航和页面切换入口 -->
|
||||
<script lang="ts" setup>
|
||||
import { Activity, LayoutDashboard, Moon, Search, Sun } from "lucide-vue-next";
|
||||
import {
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Moon,
|
||||
Search,
|
||||
Sun
|
||||
} from "lucide-vue-next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
/** 使用 VueUse 的 useColorMode 切换 dark/light,class 会挂到 <html> 上 */
|
||||
const colorMode = useColorMode();
|
||||
@@ -12,6 +21,8 @@ function toggleColorMode() {
|
||||
colorMode.value = isDark.value ? "light" : "dark";
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
/** 顶部导航项,icon 使用 lucide,和项目里 shadcn 的图标体系保持一致 */
|
||||
const navItems = [
|
||||
{
|
||||
@@ -33,6 +44,11 @@ const isActive = (to: string) => {
|
||||
if (to === "/") return route.path === "/";
|
||||
return route.path.startsWith(to);
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authStore.signOut();
|
||||
await navigateTo("/login");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,6 +98,29 @@ const isActive = (to: string) => {
|
||||
<Sun v-if="isDark" class="size-4" />
|
||||
<Moon v-else class="size-4" />
|
||||
</Button>
|
||||
|
||||
<!-- 已登录:显示邮箱和退出按钮;未登录:显示登录链接 -->
|
||||
<template v-if="authStore.isOnline">
|
||||
<span class="max-sm:hidden text-xs text-muted-foreground">
|
||||
{{ authStore.user!.email }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="退出登录"
|
||||
@click="handleSignOut"
|
||||
>
|
||||
<LogOut class="size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button as-child variant="ghost" size="sm">
|
||||
<NuxtLink to="/login" class="gap-2">
|
||||
<LogIn class="size-4" />
|
||||
<span>登录</span>
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { Slot } from 'reka-ui'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot
|
||||
:id="formItemId"
|
||||
data-slot="form-control"
|
||||
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||
:aria-invalid="!!error"
|
||||
>
|
||||
<slot />
|
||||
</Slot>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { formDescriptionId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="formDescriptionId"
|
||||
data-slot="form-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useId } from 'reka-ui'
|
||||
import { provide } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
provide(FORM_ITEM_INJECTION_KEY, id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
:class="cn('grid gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const { error, formItemId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
:data-error="!!error"
|
||||
:class="cn(
|
||||
'data-[error=true]:text-destructive',
|
||||
props.class,
|
||||
)"
|
||||
:for="formItemId"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ErrorMessage } from 'vee-validate'
|
||||
import { toValue } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { name, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ErrorMessage
|
||||
:id="formMessageId"
|
||||
data-slot="form-message"
|
||||
as="p"
|
||||
:name="toValue(name)"
|
||||
:class="cn('text-destructive text-sm', props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as FormControl } from './FormControl.vue'
|
||||
export { default as FormDescription } from './FormDescription.vue'
|
||||
export { default as FormItem } from './FormItem.vue'
|
||||
export { default as FormLabel } from './FormLabel.vue'
|
||||
export { default as FormMessage } from './FormMessage.vue'
|
||||
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export const FORM_ITEM_INJECTION_KEY
|
||||
= Symbol() as InjectionKey<string>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FieldContextKey } from 'vee-validate'
|
||||
import { computed, inject } from 'vue'
|
||||
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
|
||||
export function useFormField() {
|
||||
const fieldContext = inject(FieldContextKey)
|
||||
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
|
||||
|
||||
if (!fieldContext)
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
|
||||
const { name, errorMessage: error, meta } = fieldContext
|
||||
const id = fieldItemContext
|
||||
|
||||
const fieldState = {
|
||||
valid: computed(() => meta.valid),
|
||||
isDirty: computed(() => meta.dirty),
|
||||
isTouched: computed(() => meta.touched),
|
||||
error,
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// app/middleware/auth.ts - 路由守卫,未登录时重定向到登录页
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// 只保护 /dashboard 路径
|
||||
if (!to.path.startsWith("/dashboard")) return;
|
||||
|
||||
// SSR 阶段无浏览器 cookie,session 由 auth.client.ts 插件在客户端初始化
|
||||
// 服务端直接放行,客户端插件执行完毕后 middleware 会带着正确状态再次运行
|
||||
if (import.meta.server) return;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 等待 session 初始化完成(插件已发起,此处复用同一 Promise 去重)
|
||||
if (!authStore.initialized) {
|
||||
await authStore.fetchSession();
|
||||
}
|
||||
|
||||
if (!authStore.isOnline) {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,25 @@
|
||||
<!-- app/pages/dashboard.vue - 运行状态与最近问答记录页面 -->
|
||||
<script lang="ts" setup>
|
||||
import { ArrowPathIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
import QaRecordDetail from "@/components/dashboard/QaRecordDetail.vue";
|
||||
import QaRecordsTable from "@/components/dashboard/QaRecordsTable.vue";
|
||||
import StatsOverview from "@/components/dashboard/StatsOverview.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { useAnswerStore } from "@/stores/answer";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const answerStore = useAnswerStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
useHead({
|
||||
title: "Dashboard - OCS AI 答题服务"
|
||||
@@ -22,10 +36,50 @@ if (import.meta.client) {
|
||||
void answerStore.loadDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
const refreshing = ref(false);
|
||||
|
||||
const handleRefreshToken = async () => {
|
||||
refreshing.value = true;
|
||||
await authStore.refreshApiToken();
|
||||
refreshing.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6">
|
||||
<!-- OCS 油猴脚本 API token 配置卡片 -->
|
||||
<Card v-if="authStore.user?.apiToken">
|
||||
<CardHeader>
|
||||
<CardTitle>OCS 脚本 Token</CardTitle>
|
||||
<CardDescription>
|
||||
在 OCS 油猴脚本的服务器配置中填入此
|
||||
token,脚本将以你的账号身份调用答题接口。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="flex-1 overflow-x-auto bg-muted px-3 py-2 font-mono text-sm rounded-full"
|
||||
>
|
||||
{{ authStore.user.apiToken }}
|
||||
</code>
|
||||
<CopyCom :text="authStore.user.apiToken" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="refreshing"
|
||||
title="刷新 Token"
|
||||
@click="handleRefreshToken"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
:class="['size-4', refreshing ? 'animate-spin' : '']"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<StatsOverview />
|
||||
<QaRecordsTable />
|
||||
<QaRecordDetail />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- app/pages/login.vue - 邮箱密码登录页 -->
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
useHead({ title: "登录 - OCS AI 答题服务" });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 已登录用户访问登录页,直接跳首页
|
||||
watchEffect(() => {
|
||||
if (authStore.isOnline) navigateTo("/");
|
||||
});
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
email: z
|
||||
.string({ required_error: "邮箱不能为空" })
|
||||
.min(1, "邮箱不能为空")
|
||||
.email("请输入有效的邮箱地址"),
|
||||
password: z.string({ required_error: "密码不能为空" }).min(1, "密码不能为空")
|
||||
})
|
||||
);
|
||||
|
||||
const { handleSubmit, isSubmitting } = useForm({
|
||||
validationSchema: formSchema
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const ok = await authStore.signIn(values.email, values.password);
|
||||
if (ok) {
|
||||
await navigateTo("/");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[60vh] items-center justify-center">
|
||||
<div class="w-full max-w-sm space-y-6">
|
||||
<div class="space-y-1 text-center">
|
||||
<h1 class="text-2xl font-semibold">登录</h1>
|
||||
<p class="text-sm text-muted-foreground">使用邮箱和密码登录账号</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<p v-if="authStore.error" class="text-sm text-destructive">
|
||||
{{ authStore.error }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isSubmitting || authStore.loading || !authStore.initialized"
|
||||
class="w-full"
|
||||
>
|
||||
{{ authStore.loading ? "登录中..." : "登录" }}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
还没有账号?
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
注册
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!-- app/pages/register.vue - 新用户注册页 -->
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
useHead({ title: "注册 - OCS AI 答题服务" });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 已登录用户访问注册页,直接跳首页
|
||||
watchEffect(() => {
|
||||
if (authStore.isOnline) navigateTo("/");
|
||||
});
|
||||
|
||||
const formSchema = toTypedSchema(
|
||||
z.object({
|
||||
name: z
|
||||
.string({ required_error: "昵称不能为空" })
|
||||
.min(1, "昵称不能为空")
|
||||
.max(20, "昵称不能超过 20 个字符"),
|
||||
email: z
|
||||
.string({ required_error: "邮箱不能为空" })
|
||||
.min(1, "邮箱不能为空")
|
||||
.email("请输入有效的邮箱地址"),
|
||||
password: z
|
||||
.string({ required_error: "密码不能为空" })
|
||||
.min(8, "密码至少 8 位")
|
||||
.max(100, "密码不能超过 100 位")
|
||||
.regex(/[a-zA-Z]/, "密码必须包含英文字母")
|
||||
.regex(/[0-9]/, "密码必须包含数字")
|
||||
})
|
||||
);
|
||||
|
||||
const { handleSubmit, isSubmitting } = useForm({
|
||||
validationSchema: formSchema
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const ok = await authStore.signUp(values.name, values.email, values.password);
|
||||
if (ok) {
|
||||
await navigateTo("/");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[60vh] items-center justify-center">
|
||||
<div class="w-full max-w-sm space-y-6">
|
||||
<div class="space-y-1 text-center">
|
||||
<h1 class="text-2xl font-semibold">注册</h1>
|
||||
<p class="text-sm text-muted-foreground">创建新账号</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>昵称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="你的昵称"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少 8 位"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<p v-if="authStore.error" class="text-sm text-destructive">
|
||||
{{ authStore.error }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isSubmitting || authStore.loading || !authStore.initialized"
|
||||
class="w-full"
|
||||
>
|
||||
{{ authStore.loading ? "注册中..." : "注册" }}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
已有账号?
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
class="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
登录
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
// app/plugins/auth.client.ts - 客户端启动时拉取一次 session,供全局复用
|
||||
// .client.ts 后缀确保只在浏览器端执行,避免 SSR 阶段因无 cookie 而误判未登录
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const authStore = useAuthStore();
|
||||
await authStore.fetchSession();
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// app/stores/auth.ts - 用户登录状态管理
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
import { authClient } from "@/utils/auth";
|
||||
|
||||
/** 当前登录用户信息,apiToken 用于 OCS 油猴脚本配置 */
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** 油猴脚本使用此 token 在 body 中传递身份 */
|
||||
apiToken?: string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
/** session 是否已完成首次加载,用于区分"未登录"和"尚未检查" */
|
||||
const initialized = ref(false);
|
||||
/** 已登录且 session 已初始化完成 */
|
||||
const isOnline = computed(() => initialized.value && user.value !== null);
|
||||
|
||||
// 用于对并发调用去重,避免同一时刻发起多次 get-session 请求
|
||||
let _fetchPromise: Promise<void> | null = null;
|
||||
|
||||
/** 从服务端拉取当前 session;已在请求中时复用同一 Promise,已初始化时直接跳过 */
|
||||
const fetchSession = async () => {
|
||||
if (initialized.value) return;
|
||||
if (_fetchPromise) return _fetchPromise;
|
||||
|
||||
_fetchPromise = (async () => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const session = await authClient.getSession();
|
||||
user.value = session.data?.user
|
||||
? {
|
||||
id: session.data.user.id,
|
||||
name: session.data.user.name,
|
||||
email: session.data.user.email,
|
||||
apiToken: (session.data.user as { apiToken?: string | null })
|
||||
.apiToken
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
initialized.value = true;
|
||||
_fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return _fetchPromise;
|
||||
};
|
||||
|
||||
/** 从 better-auth 返回的 user 对象中提取并写入 store */
|
||||
const _setUserFromResult = (u: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
apiToken?: string | null;
|
||||
}) => {
|
||||
user.value = {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
apiToken: u.apiToken ?? null
|
||||
};
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
/** 邮箱密码登录 */
|
||||
const signIn = async (email: string, password: string) => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await authClient.signIn.email({ email, password });
|
||||
if (result.error) {
|
||||
error.value = "邮箱或密码错误";
|
||||
return false;
|
||||
}
|
||||
// 直接使用登录响应中的用户数据,无需重复请求 get-session
|
||||
if (result.data?.user) {
|
||||
_setUserFromResult(
|
||||
result.data.user as Parameters<typeof _setUserFromResult>[0]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
error.value = "登录失败,请重试";
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 注册新账号 */
|
||||
const signUp = async (name: string, email: string, password: string) => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await authClient.signUp.email({ name, email, password });
|
||||
if (result.error) {
|
||||
error.value = "注册失败,邮箱可能已被使用";
|
||||
return false;
|
||||
}
|
||||
if (result.data?.user) {
|
||||
_setUserFromResult(
|
||||
result.data.user as Parameters<typeof _setUserFromResult>[0]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
error.value = "注册失败,请重试";
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 退出登录 */
|
||||
const signOut = async () => {
|
||||
await authClient.signOut();
|
||||
user.value = null;
|
||||
initialized.value = false;
|
||||
};
|
||||
|
||||
/** 刷新当前用户的 apiToken;成功后同步更新 store */
|
||||
const refreshApiToken = async (): Promise<boolean> => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await $fetch<{ success: boolean; msg: string; apiToken?: string }>(
|
||||
"/api/user/refresh-token",
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (res.success && res.apiToken && user.value) {
|
||||
user.value = { ...user.value, apiToken: res.apiToken };
|
||||
return true;
|
||||
}
|
||||
error.value = res.msg || "刷新失败";
|
||||
return false;
|
||||
} catch {
|
||||
error.value = "刷新失败,请重试";
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
isOnline,
|
||||
fetchSession,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
refreshApiToken
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// app/utils/auth.ts - Better Auth 前端 Vue 客户端
|
||||
import { createAuthClient } from "better-auth/vue";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
@@ -0,0 +1,19 @@
|
||||
// app/utils/clipboard.ts - 剪贴板操作工具
|
||||
|
||||
const legacyCopy = (text: string) => {
|
||||
const input = document.createElement("input");
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
};
|
||||
|
||||
/** 复制文本,优先使用 Clipboard API,降级使用 execCommand */
|
||||
export const copy = (text: string): void => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
legacyCopy(text);
|
||||
}
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./clipboard";
|
||||
export * from "./sortableTable";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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(
|
||||
@@ -51,6 +52,9 @@ export default withNuxt(
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'vue/html-self-closing': ['error', { html: { void: 'any' } }],
|
||||
'no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
},
|
||||
)
|
||||
+5
-1
@@ -17,8 +17,10 @@
|
||||
"@prisma/client": "7.8.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/nuxt": "14.3.0",
|
||||
"better-auth": "^1.6.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -33,9 +35,11 @@
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vite-svg-loader": "^5.1.1",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7"
|
||||
"vue-router": "^5.0.7",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
|
||||
Generated
+323
@@ -29,12 +29,18 @@ importers:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.34(typescript@6.0.3))
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1(vue@3.5.34(typescript@6.0.3))(zod@3.25.76)
|
||||
'@vueuse/core':
|
||||
specifier: ^14.3.0
|
||||
version: 14.3.0(vue@3.5.34(typescript@6.0.3))
|
||||
'@vueuse/nuxt':
|
||||
specifier: 14.3.0
|
||||
version: 14.3.0(magicast@0.5.3)(nuxt@4.4.6(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.1)(@parcel/watcher@2.5.6)(@types/node@24.12.4)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(stylus@0.57.0)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.47.1)(yaml@2.9.0))(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))
|
||||
better-auth:
|
||||
specifier: ^1.6.11
|
||||
version: 1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.15.3)(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vue@3.5.34(typescript@6.0.3))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -77,6 +83,9 @@ importers:
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
vee-validate:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.1(vue@3.5.34(typescript@6.0.3))
|
||||
vite-svg-loader:
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1(vue@3.5.34(typescript@6.0.3))
|
||||
@@ -86,6 +95,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.7(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
|
||||
zod:
|
||||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.59.4
|
||||
@@ -272,6 +284,85 @@ packages:
|
||||
resolution: {integrity: sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@better-auth/core@1.6.11':
|
||||
resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@cloudflare/workers-types': '>=4'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
better-call: 1.3.5
|
||||
jose: ^6.1.0
|
||||
kysely: ^0.28.5
|
||||
nanostores: ^1.0.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.11':
|
||||
resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
drizzle-orm: ^0.45.2
|
||||
peerDependenciesMeta:
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.11':
|
||||
resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
kysely: ^0.28.17
|
||||
peerDependenciesMeta:
|
||||
kysely:
|
||||
optional: true
|
||||
|
||||
'@better-auth/memory-adapter@1.6.11':
|
||||
resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
|
||||
'@better-auth/mongo-adapter@1.6.11':
|
||||
resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
mongodb:
|
||||
optional: true
|
||||
|
||||
'@better-auth/prisma-adapter@1.6.11':
|
||||
resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
|
||||
'@better-auth/telemetry@1.6.11':
|
||||
resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.11
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
'@better-auth/utils@0.4.0':
|
||||
resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==}
|
||||
|
||||
'@better-fetch/fetch@1.1.21':
|
||||
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||
|
||||
'@bomb.sh/tab@0.0.15':
|
||||
resolution: {integrity: sha512-Y90ub44TAvbdO9P8mcD/XPyQjFhiR5xmd4Fk7JErmWmEWEUimNnjWiBrVZ16Tj3GA1rLZ+uvCN2V/pzLawv31g==}
|
||||
hasBin: true
|
||||
@@ -836,6 +927,10 @@ packages:
|
||||
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/ciphers@2.2.0':
|
||||
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/curves@1.9.7':
|
||||
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
@@ -844,6 +939,10 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -969,6 +1068,10 @@ packages:
|
||||
rollup-plugin-visualizer:
|
||||
optional: true
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1':
|
||||
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@oxc-minify/binding-android-arm-eabi@0.131.0':
|
||||
resolution: {integrity: sha512-yLa7y9jjJgUeUUMm6AtjmBIQzK1YU5sYcNJnVVtr6WtoWu5SpuNDZ8u6cl/dhn0g/oQgVlf+E+8WJfsExt8R+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2289,6 +2392,11 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vee-validate/zod@4.15.1':
|
||||
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vercel/nft@1.5.0':
|
||||
resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2597,6 +2705,76 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
better-auth@1.6.11:
|
||||
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
|
||||
peerDependencies:
|
||||
'@lynx-js/react': '*'
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
'@sveltejs/kit': ^2.0.0
|
||||
'@tanstack/react-start': ^1.0.0
|
||||
'@tanstack/solid-start': ^1.0.0
|
||||
better-sqlite3: ^12.0.0
|
||||
drizzle-kit: '>=0.31.4'
|
||||
drizzle-orm: ^0.45.2
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
mysql2: ^3.0.0
|
||||
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
pg: ^8.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
solid-js: ^1.0.0
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@lynx-js/react':
|
||||
optional: true
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
'@tanstack/solid-start':
|
||||
optional: true
|
||||
better-sqlite3:
|
||||
optional: true
|
||||
drizzle-kit:
|
||||
optional: true
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
mongodb:
|
||||
optional: true
|
||||
mysql2:
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
pg:
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vitest:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
better-call@1.3.5:
|
||||
resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
better-result@2.9.2:
|
||||
resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==}
|
||||
|
||||
@@ -3896,6 +4074,10 @@ packages:
|
||||
knitwork@1.3.0:
|
||||
resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==}
|
||||
|
||||
kysely@0.28.17:
|
||||
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
launch-editor@2.13.2:
|
||||
resolution: {integrity: sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==}
|
||||
|
||||
@@ -4191,6 +4373,10 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanostores@1.3.0:
|
||||
resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
nanotar@0.3.0:
|
||||
resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==}
|
||||
|
||||
@@ -4895,6 +5081,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rou3@0.7.12:
|
||||
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
|
||||
|
||||
rou3@0.8.1:
|
||||
resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==}
|
||||
|
||||
@@ -4966,6 +5155,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-cookie-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -5483,6 +5675,11 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vee-validate@4.15.1:
|
||||
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vite-dev-rpc@1.1.0:
|
||||
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
|
||||
peerDependencies:
|
||||
@@ -5780,6 +5977,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
@@ -6002,6 +6202,60 @@ snapshots:
|
||||
'@babel/helper-string-parser': 8.0.0-rc.5
|
||||
'@babel/helper-validator-identifier': 8.0.0-rc.5
|
||||
|
||||
'@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.3.5(zod@4.4.3)
|
||||
jose: 6.2.3
|
||||
kysely: 0.28.17
|
||||
nanostores: 1.3.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
optionalDependencies:
|
||||
kysely: 0.28.17
|
||||
|
||||
'@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
|
||||
'@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
|
||||
'@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
optionalDependencies:
|
||||
'@prisma/client': 7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)
|
||||
prisma: 7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
|
||||
'@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
'@better-auth/utils@0.4.0':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
'@bomb.sh/tab@0.0.15(cac@6.7.14)(citty@0.2.2)':
|
||||
optionalDependencies:
|
||||
cac: 6.7.14
|
||||
@@ -6450,12 +6704,16 @@ snapshots:
|
||||
|
||||
'@noble/ciphers@1.3.0': {}
|
||||
|
||||
'@noble/ciphers@2.2.0': {}
|
||||
|
||||
'@noble/curves@1.9.7':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -6834,6 +7092,8 @@ snapshots:
|
||||
- vue-tsc
|
||||
- yaml
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1': {}
|
||||
|
||||
'@oxc-minify/binding-android-arm-eabi@0.131.0':
|
||||
optional: true
|
||||
|
||||
@@ -7787,6 +8047,14 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||
optional: true
|
||||
|
||||
'@vee-validate/zod@4.15.1(vue@3.5.34(typescript@6.0.3))(zod@3.25.76)':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
vee-validate: 4.15.1(vue@3.5.34(typescript@6.0.3))
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vercel/nft@1.5.0(rollup@4.60.4)':
|
||||
dependencies:
|
||||
'@mapbox/node-pre-gyp': 2.0.3
|
||||
@@ -8147,6 +8415,45 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.31: {}
|
||||
|
||||
better-auth@1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(mysql2@3.15.3)(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vue@3.5.34(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
|
||||
'@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)
|
||||
'@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
|
||||
'@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
|
||||
'@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))
|
||||
'@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@3.25.76))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.2.0
|
||||
'@noble/hashes': 2.2.0
|
||||
better-call: 1.3.5(zod@4.4.3)
|
||||
defu: 6.1.7
|
||||
jose: 6.2.3
|
||||
kysely: 0.28.17
|
||||
nanostores: 1.3.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
'@prisma/client': 7.8.0(prisma@7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)
|
||||
mysql2: 3.15.3
|
||||
prisma: 7.8.0(@types/react@19.2.15)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
vue: 3.5.34(typescript@6.0.3)
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
|
||||
better-call@1.3.5(zod@4.4.3):
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.4.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
rou3: 0.7.12
|
||||
set-cookie-parser: 3.1.0
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
better-result@2.9.2: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
@@ -9464,6 +9771,8 @@ snapshots:
|
||||
|
||||
knitwork@1.3.0: {}
|
||||
|
||||
kysely@0.28.17: {}
|
||||
|
||||
launch-editor@2.13.2:
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
@@ -9734,6 +10043,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanostores@1.3.0: {}
|
||||
|
||||
nanotar@0.3.0: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
@@ -10707,6 +11018,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.4
|
||||
fsevents: 2.3.3
|
||||
|
||||
rou3@0.7.12: {}
|
||||
|
||||
rou3@0.8.1: {}
|
||||
|
||||
router@2.2.0:
|
||||
@@ -10784,6 +11097,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-cookie-parser@3.1.0: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shadcn-nuxt@2.7.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(magicast@0.5.3):
|
||||
@@ -11368,6 +11683,12 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vee-validate@4.15.1(vue@3.5.34(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.9
|
||||
type-fest: 4.41.0
|
||||
vue: 3.5.34(typescript@6.0.3)
|
||||
|
||||
vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.47.1)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
birpc: 2.9.0
|
||||
@@ -11667,3 +11988,5 @@ snapshots:
|
||||
zod: 3.25.76
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
+77
-15
@@ -1,7 +1,5 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||
// prisma/schema.prisma - OCS 答题服务数据库 schema
|
||||
// 包含 Better Auth 认证表(User, Session, Account, Verification)和业务表(QaRecord)
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -12,18 +10,82 @@ datasource db {
|
||||
provider = "mysql"
|
||||
}
|
||||
|
||||
// Better Auth 标准用户表
|
||||
// apiToken 由服务端注册 hook 生成,供 OCS 油猴脚本跨域身份验证
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
posts Post[]
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean
|
||||
image String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
apiToken String? @unique
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
qaRecords QaRecord[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String?
|
||||
published Boolean @default(false)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
authorId Int
|
||||
// Better Auth 会话表
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String @unique
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
// Better Auth 账号表(用于邮密和 OAuth Provider 关联)
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String? @db.Text
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
// Better Auth 邮箱验证令牌表
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime?
|
||||
updatedAt DateTime?
|
||||
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
// 用户问答记录,按用户隔离存储
|
||||
// search 接口在拿到有效 session 或 apiToken 后写入,与当前登录用户绑定
|
||||
model QaRecord {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
question String @db.Text
|
||||
type String
|
||||
options String? @db.Text
|
||||
answer String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("qa_record")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// server/api/auth/[...all].ts - Better Auth catch-all 路由,处理所有 /api/auth/* 请求
|
||||
import { toWebRequest } from "h3";
|
||||
|
||||
import { auth } from "~~/server/utils/auth";
|
||||
|
||||
/**
|
||||
* 将所有 /api/auth/* 请求转发给 Better Auth handler
|
||||
*
|
||||
* toWebRequest 把 Nitro H3Event 转为标准 Web API Request;
|
||||
* Better Auth handler 返回标准 Web API Response,Nitro 会自动将其转发给客户端
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
});
|
||||
Vendored
+2
-4
@@ -4,11 +4,10 @@ import { setResponseStatus } from "h3";
|
||||
import {
|
||||
invalidAccessTokenResponse,
|
||||
verifyAccessToken
|
||||
} from "~~/server/utils/auth";
|
||||
} from "~~/server/utils/accessToken";
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
import { clearQaRecords } from "~~/server/utils/runtimeState";
|
||||
|
||||
/**
|
||||
* 清空内存缓存
|
||||
@@ -37,9 +36,8 @@ export default defineEventHandler((event) => {
|
||||
};
|
||||
}
|
||||
|
||||
// 清空当前进程内缓存和问答记录,不影响运行统计中的 uptime
|
||||
// 清空当前进程内缓存,DB 记录不受影响
|
||||
answerCache.clear();
|
||||
clearQaRecords();
|
||||
logger.info("finish_success");
|
||||
|
||||
return {
|
||||
|
||||
+20
-27
@@ -1,33 +1,28 @@
|
||||
// server/api/records.get.ts - 最近问答记录接口,供 Nuxt Dashboard 表格展示
|
||||
// server/api/records.get.ts - 用户问答记录接口,供 Nuxt Dashboard 表格展示
|
||||
import { getQuery, setResponseStatus } from "h3";
|
||||
|
||||
import {
|
||||
invalidAccessTokenResponse,
|
||||
verifyAccessToken
|
||||
} from "~~/server/utils/auth";
|
||||
import { getAuthSession } 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 或内部错误
|
||||
* - 记录仅存在当前进程内,重启或多实例部署不会共享
|
||||
* - 必须携带有效 session cookie(登录用户)
|
||||
* - 只返回当前登录用户自己的记录,用 userId 严格隔离
|
||||
* - 不包含 OpenAI 响应体、API Key 或内部错误
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.records");
|
||||
|
||||
if (!verifyAccessToken(event)) {
|
||||
setResponseStatus(event, 403);
|
||||
logger.warn("invalid_access_token");
|
||||
const session = await getAuthSession(event);
|
||||
if (!session) {
|
||||
setResponseStatus(event, 401);
|
||||
logger.warn("unauthorized");
|
||||
return {
|
||||
success: false,
|
||||
message: invalidAccessTokenResponse().msg,
|
||||
message: "未登录",
|
||||
records: [],
|
||||
page: 1,
|
||||
size: 10,
|
||||
@@ -41,25 +36,23 @@ export default defineEventHandler((event) => {
|
||||
const size = Number.isFinite(rawSize)
|
||||
? Math.min(Math.max(rawSize, 1), 100)
|
||||
: 10;
|
||||
const records = getQaRecords();
|
||||
const total = records.length;
|
||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||
const page = Number.isFinite(rawPage)
|
||||
? Math.min(Math.max(rawPage, 1), maxPage)
|
||||
: 1;
|
||||
const start = (page - 1) * size;
|
||||
const pageRecords = records.slice(start, start + size);
|
||||
const page = Number.isFinite(rawPage) ? Math.max(rawPage, 1) : 1;
|
||||
|
||||
const { records, total } = await getQaRecords(session.user.id, {
|
||||
page,
|
||||
size
|
||||
});
|
||||
|
||||
logger.info("finish_success", {
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
count: pageRecords.length
|
||||
count: records.length
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records: pageRecords,
|
||||
records,
|
||||
page,
|
||||
size,
|
||||
total
|
||||
|
||||
+46
-16
@@ -4,7 +4,8 @@ import {
|
||||
type H3Event,
|
||||
readBody,
|
||||
readFormData,
|
||||
setResponseStatus} from "h3";
|
||||
setResponseStatus
|
||||
} from "h3";
|
||||
|
||||
import {
|
||||
ANSWER_SYSTEM_PROMPT,
|
||||
@@ -14,8 +15,9 @@ import {
|
||||
parseQuestionAndOptions,
|
||||
type SearchParams
|
||||
} from "~~/server/utils/answer";
|
||||
import { invalidAccessTokenResponse, verifyAccessToken } from "~~/server/utils/auth";
|
||||
import { getAuthSession } from "~~/server/utils/auth";
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger, toSafeLogError } from "~~/server/utils/logging";
|
||||
import { askAnswerStream } from "~~/server/utils/openai";
|
||||
import { addQaRecord } from "~~/server/utils/runtimeState";
|
||||
@@ -36,6 +38,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
/** SearchParams 扩展,包含 OCS 脚本通过 body 传入的 apiToken */
|
||||
type SearchParamsWithToken = SearchParams & { token?: string };
|
||||
|
||||
/**
|
||||
* 兼容旧 Python 服务的三种入参方式
|
||||
*
|
||||
@@ -48,7 +53,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
*/
|
||||
const readSearchParams = async (
|
||||
event: H3Event
|
||||
): Promise<SearchParams | "invalid_body"> => {
|
||||
): Promise<SearchParamsWithToken | "invalid_body"> => {
|
||||
const method = event.node.req.method?.toUpperCase() || "GET";
|
||||
|
||||
if (method === "GET") {
|
||||
@@ -57,7 +62,8 @@ const readSearchParams = async (
|
||||
return {
|
||||
title: toStringValue(query.title).trim(),
|
||||
type: toStringValue(query.type).trim(),
|
||||
options: toStringValue(query.options).trim()
|
||||
options: toStringValue(query.options).trim(),
|
||||
token: toStringValue(query.token).trim() || undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +95,8 @@ const readSearchParams = async (
|
||||
return {
|
||||
title: toStringValue(body.title).trim(),
|
||||
type: toStringValue(body.type).trim(),
|
||||
options: toStringValue(body.options).trim()
|
||||
options: toStringValue(body.options).trim(),
|
||||
token: toStringValue(body.token).trim() || undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -97,15 +104,18 @@ const readSearchParams = async (
|
||||
* OCS 答题搜索主接口
|
||||
*
|
||||
* 流程:
|
||||
* - 校验请求方法和可选访问令牌
|
||||
* - 校验请求方法
|
||||
* - 鉴权:优先读 session(浏览器登录),无 session 则读 body.token(OCS 油猴脚本)
|
||||
* - 两种识别方式均无效时返回 401
|
||||
* - 读取题目、题型、选项,兼容 GET/JSON/form
|
||||
* - 先查内存缓存,命中后不再请求 OpenAI
|
||||
* - 未命中时拼提示词,服务端流式请求 Chat Completions
|
||||
* - 清洗答案、写入缓存和最近问答记录,最后返回 OCS 兼容 JSON
|
||||
* - 清洗答案、写入缓存和 DB 记录,最后返回 OCS 兼容 JSON
|
||||
*
|
||||
* 安全边界:
|
||||
* - 不向前端返回 OpenAI 错误、响应体、API Key 或内部堆栈
|
||||
* - 日志只记录长度、阶段、耗时等摘要,不记录完整题目和完整 prompt
|
||||
* - apiToken 不进日志
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.search");
|
||||
@@ -118,13 +128,6 @@ export default defineEventHandler(async (event) => {
|
||||
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);
|
||||
@@ -133,6 +136,27 @@ export default defineEventHandler(async (event) => {
|
||||
return createOcsErrorResponse("请求体必须是 JSON 对象");
|
||||
}
|
||||
|
||||
// 鉴权:优先 session(浏览器登录),无 session 则用 body.token 查 apiToken
|
||||
// OCS 油猴脚本跨域无法携带 cookie,需在 body 中传入用户自己的 apiToken
|
||||
let userId: string | null = null;
|
||||
|
||||
const session = await getAuthSession(event);
|
||||
if (session) {
|
||||
userId = session.user.id;
|
||||
} else if (params.token) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { apiToken: params.token },
|
||||
select: { id: true }
|
||||
});
|
||||
userId = user?.id ?? null;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
setResponseStatus(event, 401);
|
||||
logger.warn("unauthorized");
|
||||
return createOcsErrorResponse("请先登录或提供有效 token");
|
||||
}
|
||||
|
||||
logger.info("read_question", {
|
||||
questionLength: params.title.length,
|
||||
type: params.type,
|
||||
@@ -168,8 +192,14 @@ export default defineEventHandler(async (event) => {
|
||||
const processedAnswer = extractAnswer(streamResult.answer, params.type);
|
||||
|
||||
// 先缓存再记录;这两步失败风险很低,且都是内存操作,不会阻塞主链路
|
||||
answerCache?.set(params.title, processedAnswer, params.type, params.options);
|
||||
addQaRecord({
|
||||
answerCache?.set(
|
||||
params.title,
|
||||
processedAnswer,
|
||||
params.type,
|
||||
params.options
|
||||
);
|
||||
await addQaRecord({
|
||||
userId,
|
||||
question: params.title,
|
||||
type: params.type,
|
||||
options: params.options,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { setResponseStatus } from "h3";
|
||||
import {
|
||||
invalidAccessTokenResponse,
|
||||
verifyAccessToken
|
||||
} from "~~/server/utils/auth";
|
||||
} from "~~/server/utils/accessToken";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
import { getRuntimeStats } from "~~/server/utils/runtimeState";
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// server/api/user/refresh-token.post.ts - 刷新当前登录用户的 apiToken
|
||||
// 鉴权后生成新 token 写入数据库,不对外暴露旧 token 或内部错误
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { setResponseStatus } from "h3";
|
||||
|
||||
import { getAuthSession } from "~~/server/utils/auth";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
import { createApiLogger } from "~~/server/utils/logging";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const logger = createApiLogger(event, "api.user.refresh-token");
|
||||
|
||||
const session = await getAuthSession(event);
|
||||
if (!session) {
|
||||
setResponseStatus(event, 401);
|
||||
logger.warn("unauthorized");
|
||||
return { success: false, msg: "未登录" };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const newToken = randomBytes(16).toString("hex");
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { apiToken: newToken }
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("db_error", { userId, err: String(err) });
|
||||
setResponseStatus(event, 500);
|
||||
return { success: false, msg: "服务器内部错误" };
|
||||
}
|
||||
|
||||
logger.info("finish_success", { userId });
|
||||
return { success: true, msg: "刷新成功", apiToken: newToken };
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// server/utils/accessToken.ts - OCS API 可选访问令牌校验(用于 stats/cache 等管理接口)
|
||||
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: "无效的访问令牌"
|
||||
};
|
||||
};
|
||||
+57
-32
@@ -1,42 +1,67 @@
|
||||
// server/utils/auth.ts - OCS API 可选访问令牌校验
|
||||
import { getQuery, type H3Event } from "h3";
|
||||
// server/utils/auth.ts - Better Auth 服务端实例,邮箱密码登录 + 用户 API token
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import type { H3Event } from "h3";
|
||||
import { toWebRequest } from "h3";
|
||||
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
|
||||
/**
|
||||
* H3 的 query 值可能是字符串、数组或 undefined
|
||||
* 访问令牌只接受第一个值,和多数 Web 框架读取 query 的行为保持一致
|
||||
*/
|
||||
const firstQueryValue = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value[0]?.toString() || "";
|
||||
return value?.toString() || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验可选访问令牌
|
||||
* Better Auth 全局实例
|
||||
*
|
||||
* - 没配置 `ACCESS_TOKEN` 时,服务保持旧 Python 项目的开放行为
|
||||
* - 配置后,兼容两种传递方式:`X-Access-Token` 请求头或 `?token=...`
|
||||
* - 使用 Prisma MySQL/MariaDB 适配器
|
||||
* - 只启用邮箱/密码登录,无邮箱验证和密码重置(保持最简)
|
||||
* - 注册时通过 databaseHook 自动生成 32 位 hex apiToken
|
||||
* apiToken 供 OCS 油猴脚本跨域调用 /api/search 时在 body 中携带
|
||||
* - apiToken 不允许客户端直接设置(input: false),但随 session 返回给登录用户
|
||||
*/
|
||||
export const verifyAccessToken = (event: H3Event) => {
|
||||
if (!serverEnv.accessToken) return true;
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, { provider: "mysql" }),
|
||||
|
||||
const headerToken = event.node.req.headers["x-access-token"]?.toString();
|
||||
const queryToken = firstQueryValue(getQuery(event).token);
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 14, // 14 天过期
|
||||
updateAge: 60 * 60 * 24 // 每天自动续期
|
||||
},
|
||||
|
||||
return (
|
||||
headerToken === serverEnv.accessToken ||
|
||||
queryToken === serverEnv.accessToken
|
||||
);
|
||||
};
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
apiToken: {
|
||||
type: "string",
|
||||
required: false,
|
||||
// 客户端(signup/updateUser)不能设置此字段,只由 databaseHooks 写入
|
||||
input: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
// 注册时自动生成 apiToken,格式与旧 ACCESS_TOKEN 字段一致(32 位 hex)
|
||||
before: async (user) => {
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
apiToken: randomBytes(16).toString("hex")
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* OCS 搜索接口使用 `{ code, msg }`,而 stats/cache 使用 `{ success, message }`
|
||||
* 这里先返回最基础的 OCS 形状,其他接口复用其中的 msg 文案
|
||||
* 从 H3 事件中读取 Better Auth session
|
||||
*
|
||||
* 封装 toWebRequest 转换,避免在每个 handler 中重复导入和调用
|
||||
* 未登录或 session 无效时返回 null
|
||||
*/
|
||||
export const invalidAccessTokenResponse = () => {
|
||||
return {
|
||||
code: 0,
|
||||
msg: "无效的访问令牌"
|
||||
};
|
||||
};
|
||||
export const getAuthSession = (event: H3Event) =>
|
||||
auth.api.getSession({ headers: toWebRequest(event).headers });
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
// server/utils/runtimeState.ts - 服务启动时间和最近问答记录的内存状态
|
||||
// server/utils/runtimeState.ts - 服务启动时间内存状态 + 问答记录 DB 读写
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { prisma } from "~~/server/utils/db";
|
||||
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) => {
|
||||
export const formatLocalDateTime = (date: Date) => {
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
@@ -40,35 +21,60 @@ const formatLocalDateTime = (date: Date) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 追加一条问答记录
|
||||
*
|
||||
* 记录只用于统计和未来可能恢复的 dashboard,不作为题库持久化数据
|
||||
* 向数据库写入一条问答记录
|
||||
*/
|
||||
export const addQaRecord = (record: Omit<QaRecord, "time" | "timestamp">) => {
|
||||
const now = new Date();
|
||||
|
||||
qaRecords.push({
|
||||
time: formatLocalDateTime(now),
|
||||
timestamp: now.toISOString(),
|
||||
...record
|
||||
export const addQaRecord = async (record: {
|
||||
userId: string;
|
||||
question: string;
|
||||
type: string;
|
||||
options: string;
|
||||
answer: string;
|
||||
}) => {
|
||||
await prisma.qaRecord.create({
|
||||
data: {
|
||||
userId: record.userId,
|
||||
question: record.question,
|
||||
type: record.type,
|
||||
options: record.options || null,
|
||||
answer: record.answer || null
|
||||
}
|
||||
});
|
||||
|
||||
if (qaRecords.length > MAX_RECORDS) {
|
||||
qaRecords.shift();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取最近问答记录
|
||||
*
|
||||
* 返回副本并按“最新在前”排序,避免 API handler 或前端展示逻辑误改内存原数组
|
||||
* 从数据库分页读取指定用户的问答记录,按创建时间倒序
|
||||
*/
|
||||
export const getQaRecords = () => {
|
||||
return [...qaRecords].reverse();
|
||||
};
|
||||
/** 清空进程内问答记录,通常和 answerCache.clear() 一起调用 */
|
||||
export const clearQaRecords = () => {
|
||||
qaRecords.length = 0;
|
||||
export const getQaRecords = async (
|
||||
userId: string,
|
||||
options: { page: number; size: number }
|
||||
) => {
|
||||
const { page, size } = options;
|
||||
const skip = (page - 1) * size;
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.qaRecord.count({ where: { userId } }),
|
||||
prisma.qaRecord.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: size,
|
||||
select: {
|
||||
question: true,
|
||||
type: true,
|
||||
options: true,
|
||||
answer: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
const records = rows.map((r) => ({
|
||||
time: formatLocalDateTime(r.createdAt),
|
||||
timestamp: r.createdAt.toISOString(),
|
||||
question: r.question,
|
||||
type: r.type,
|
||||
options: r.options ?? "",
|
||||
answer: r.answer ?? ""
|
||||
}));
|
||||
return { records, total };
|
||||
};
|
||||
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
||||
export const getRuntimeStats = () => {
|
||||
@@ -77,7 +83,6 @@ export const getRuntimeStats = () => {
|
||||
uptime: (Date.now() - startTime) / 1000,
|
||||
model: serverEnv.openAiModel,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
cache_size: answerCache?.size() ?? 0,
|
||||
qa_records_count: qaRecords.length
|
||||
cache_size: answerCache?.size() ?? 0
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"better-auth-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "better-auth/best-practices/SKILL.md",
|
||||
"computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f"
|
||||
},
|
||||
"better-auth-security-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "security/SKILL.md",
|
||||
"computedHash": "ed38caef4b297cc399d717258d6ea4e6ecb8894c453c83450064c7e7c6e5cc02"
|
||||
},
|
||||
"create-auth-skill": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "better-auth/create-auth/SKILL.md",
|
||||
"computedHash": "393e8d2d795fa5797c9a4e0665f29183ea2e140233db59746d510340a10456e6"
|
||||
},
|
||||
"email-and-password-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "better-auth/emailAndPassword/SKILL.md",
|
||||
"computedHash": "7786d722fa682b3d6a99793e73a9d51b59becefa595676134a993020633e068f"
|
||||
},
|
||||
"organization-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "better-auth/organization/SKILL.md",
|
||||
"computedHash": "fa7a76e45a1f9632e6d63e92b476cf566e64f6e93a2c8ee00bc77b4a74008fbd"
|
||||
},
|
||||
"two-factor-authentication-best-practices": {
|
||||
"source": "better-auth/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "better-auth/twoFactor/SKILL.md",
|
||||
"computedHash": "c4f3a299c62c1985b774b7c28e53b7e55c565dbf973fd52b08899e4bcfffa3f9"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user