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
+72
View File
@@ -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>
+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>
+40 -1
View File
@@ -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/lightclass 会挂到 <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>
+17
View File
@@ -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>
+23
View File
@@ -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>
+25
View File
@@ -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>
+23
View File
@@ -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>
+7
View File
@@ -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'
+4
View File
@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'
export const FORM_ITEM_INJECTION_KEY
= Symbol() as InjectionKey<string>
+30
View File
@@ -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,
}
}
+22
View File
@@ -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 阶段无浏览器 cookiesession 由 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");
}
});
+54
View File
@@ -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 />
+112
View File
@@ -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>
+136
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
// app/plugins/auth.client.ts - 客户端启动时拉取一次 session,供全局复用
// .client.ts 后缀确保只在浏览器端执行,避免 SSR 阶段因无 cookie 而误判未登录
export default defineNuxtPlugin(async () => {
const authStore = useAuthStore();
await authStore.fetchSession();
});
+165
View File
@@ -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
};
});
+4
View File
@@ -0,0 +1,4 @@
// app/utils/auth.ts - Better Auth 前端 Vue 客户端
import { createAuthClient } from "better-auth/vue";
export const authClient = createAuthClient();
+19
View File
@@ -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
View File
@@ -1 +1,2 @@
export * from "./clipboard";
export * from "./sortableTable";