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,
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user