feat: 新增鉴权
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user