182 lines
5.4 KiB
Vue
182 lines
5.4 KiB
Vue
<!-- 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 {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle
|
||
} from "@/components/ui/card";
|
||
import {
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage
|
||
} from "@/components/ui/form";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue
|
||
} from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import type { QuestionType } from "@/interfaces";
|
||
import { QUESTION_TYPE_OPTIONS, useAnswerStore } from "@/stores/answer";
|
||
|
||
const answerStore = useAnswerStore();
|
||
const { question, questionType, options, searchLoading } =
|
||
storeToRefs(answerStore);
|
||
|
||
const authStore = useAuthStore();
|
||
/** session 尚未初始化完成(客户端 plugin 还在请求中) */
|
||
const sessionChecking = computed(() => !authStore.initialized);
|
||
const AUTO_TYPE_VALUE = "__auto__";
|
||
|
||
/** shadcn Select 不使用空字符串作为 item value,这里只在组件边界做一次映射 */
|
||
const selectedQuestionType = computed({
|
||
get: () => questionType.value || AUTO_TYPE_VALUE,
|
||
set: (value: string) => {
|
||
questionType.value =
|
||
value === AUTO_TYPE_VALUE ? "" : (value as QuestionType);
|
||
}
|
||
});
|
||
|
||
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>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="text-xl">题目查询</CardTitle>
|
||
<CardDescription
|
||
>服务端流式请求模型,前端返回普通 JSON 结果。</CardDescription
|
||
>
|
||
</CardHeader>
|
||
|
||
<CardContent>
|
||
<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 class="text-sm font-medium leading-none">题型</label>
|
||
<Select v-model="selectedQuestionType">
|
||
<SelectTrigger class="w-full">
|
||
<SelectValue placeholder="自动判断" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="item in QUESTION_TYPE_OPTIONS"
|
||
:key="item.value || AUTO_TYPE_VALUE"
|
||
:value="item.value || AUTO_TYPE_VALUE"
|
||
>
|
||
{{ item.label }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<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">
|
||
<!-- 已登录或 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>
|
||
</Card>
|
||
</template>
|