feat: 新增鉴权

This commit is contained in:
2026-05-22 22:38:01 +08:00
parent b02f15f735
commit 3857f77f8b
38 changed files with 1697 additions and 195 deletions
+100 -27
View File
@@ -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. 选项一&#10;B. 选项二"
/>
</div>
<FormField v-slot="{ componentField }" name="options">
<FormItem>
<FormLabel>选项</FormLabel>
<FormControl>
<Textarea
class="min-h-24 resize-y"
placeholder="A. 选项一&#10;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>
+15 -24
View File
@@ -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>