feat(auth): implement user authentication and email verification

Add registration, login, and logout flows with session management
Integrate Resend for email verification tokens
Create frontend auth views and update topbar state
This commit is contained in:
2026-04-30 11:32:46 +08:00
parent 193b4e3fd5
commit 9af8c98401
13 changed files with 898 additions and 11 deletions

View File

@@ -3,4 +3,8 @@ POSTGRES_USER=pokopia
POSTGRES_PASSWORD=pokopia
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
BACKEND_PORT=3001
FRONTEND_ORIGIN=http://localhost:3000
APP_ORIGIN=http://localhost:3000
VITE_API_BASE_URL=http://localhost:3001
RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"

View File

@@ -113,4 +113,19 @@ Eg: 名称:乱撒,二级分类:棉花
- 材料单详情页
- 基本信息
- 入手方式
- 需要材料列表
- 需要材料列表
## 用户系统
- 用户可注册
- 邮箱
- 显示名
- 密码
- 用户注册后需要通过邮箱验证
- 使用 Resend 发送验证邮件
- 邮件内包含验证链接
- 用户可登录
- 仅允许已验证邮箱的用户登录
- 登录后可获取当前用户信息
- 用户可退出登录
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据

View File

@@ -3,6 +3,41 @@ CREATE TABLE IF NOT EXISTS environments (
name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
display_name text NOT NULL,
password_hash text NOT NULL,
email_verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (email = lower(email)),
CHECK (length(display_name) BETWEEN 1 AND 40)
);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens(user_id);
CREATE TABLE IF NOT EXISTS user_sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
ON user_sessions(user_id);
CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,

338
backend/src/auth.ts Normal file
View File

@@ -0,0 +1,338 @@
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import type { PoolClient, QueryResultRow } from 'pg';
import { pool, queryOne } from './db.ts';
const scrypt = promisify(scryptCallback);
const passwordKeyLength = 64;
const verificationTokenHours = 24;
const sessionDays = 30;
type DbClient = PoolClient;
type StatusError = Error & { statusCode: number };
type UserRow = QueryResultRow & {
id: number;
email: string;
display_name: string;
email_verified_at: string | null;
};
type LoginUserRow = UserRow & {
password_hash: string;
};
export type AuthUser = {
id: number;
email: string;
displayName: string;
emailVerified: boolean;
};
function statusError(message: string, statusCode: number): StatusError {
const error = new Error(message) as StatusError;
error.statusCode = statusCode;
return error;
}
function cleanEmail(value: unknown): string {
if (typeof value !== 'string') {
throw statusError('请输入邮箱', 400);
}
const email = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw statusError('邮箱格式不正确', 400);
}
return email;
}
function cleanDisplayName(value: unknown): string {
if (typeof value !== 'string') {
throw statusError('请输入显示名', 400);
}
const displayName = value.trim();
if (displayName.length < 1 || displayName.length > 40) {
throw statusError('显示名长度需为 1 到 40 个字符', 400);
}
return displayName;
}
function cleanPassword(value: unknown): string {
if (typeof value !== 'string' || value.length < 8) {
throw statusError('密码至少需要 8 个字符', 400);
}
return value;
}
function cleanToken(value: unknown): string {
if (typeof value !== 'string' || value.trim().length < 32) {
throw statusError('验证链接无效或已过期', 400);
}
return value.trim();
}
function toPublicUser(user: UserRow): AuthUser {
return {
id: user.id,
email: user.email,
displayName: user.display_name,
emailVerified: user.email_verified_at !== null
};
}
async function clientQueryOne<T extends QueryResultRow>(
client: DbClient,
sql: string,
params: unknown[] = []
): Promise<T | null> {
const result = await client.query<T>(sql, params);
return result.rows[0] ?? null;
}
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('base64url');
const key = (await scrypt(password, salt, passwordKeyLength)) as Buffer;
return `scrypt$${salt}$${key.toString('base64url')}`;
}
async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
const [algorithm, salt, storedKey] = passwordHash.split('$');
if (algorithm !== 'scrypt' || !salt || !storedKey) {
return false;
}
const storedBuffer = Buffer.from(storedKey, 'base64url');
const key = (await scrypt(password, salt, storedBuffer.length)) as Buffer;
return key.length === storedBuffer.length && timingSafeEqual(key, storedBuffer);
}
function createPlainToken(): string {
return randomBytes(32).toString('base64url');
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function getEmailConfig() {
const apiKey = process.env.RESEND_API_KEY;
const from = process.env.EMAIL_FROM;
if (!apiKey || !from) {
throw new Error('Email service is not configured');
}
return { apiKey, from };
}
function buildVerificationUrl(token: string): string {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const url = new URL('/verify-email', origin);
url.searchParams.set('token', token);
return url.toString();
}
async function sendVerificationEmail(email: string, token: string): Promise<void> {
const { apiKey, from } = getEmailConfig();
const verificationUrl = buildVerificationUrl(token);
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from,
to: [email],
subject: '验证你的 Pokopia Wiki 邮箱',
html: `<p>请点击下面的链接完成邮箱验证:</p><p><a href="${verificationUrl}">验证邮箱</a></p><p>链接将在 ${verificationTokenHours} 小时后失效。</p>`,
text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。`
})
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
}
}
export async function registerUser(payload: Record<string, unknown>) {
const email = cleanEmail(payload.email);
const displayName = cleanDisplayName(payload.displayName);
const password = cleanPassword(payload.password);
const passwordHash = await hashPassword(password);
const verificationToken = createPlainToken();
const verificationTokenHash = hashToken(verificationToken);
await withTransaction(async (client) => {
const existingUser = await clientQueryOne<UserRow>(
client,
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
[email]
);
if (existingUser?.email_verified_at) {
throw statusError('该邮箱已注册', 409);
}
const user = existingUser
? await clientQueryOne<UserRow>(
client,
`
UPDATE users
SET display_name = $1, password_hash = $2, updated_at = now()
WHERE id = $3
RETURNING id, email, display_name, email_verified_at
`,
[displayName, passwordHash, existingUser.id]
)
: await clientQueryOne<UserRow>(
client,
`
INSERT INTO users (email, display_name, password_hash)
VALUES ($1, $2, $3)
RETURNING id, email, display_name, email_verified_at
`,
[email, displayName, passwordHash]
);
if (!user) {
throw new Error('Failed to save user');
}
await client.query('DELETE FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
await client.query(
`
INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + ($3 * interval '1 hour'))
`,
[user.id, verificationTokenHash, verificationTokenHours]
);
});
await sendVerificationEmail(email, verificationToken);
return { message: '请查收验证邮件' };
}
export async function verifyEmail(payload: Record<string, unknown>) {
const token = cleanToken(payload.token);
const tokenHash = hashToken(token);
return withTransaction(async (client) => {
const tokenRow = await clientQueryOne<{ id: number; user_id: number }>(
client,
`
SELECT id, user_id
FROM email_verification_tokens
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
FOR UPDATE
`,
[tokenHash]
);
if (!tokenRow) {
throw statusError('验证链接无效或已过期', 400);
}
const user = await clientQueryOne<UserRow>(
client,
`
UPDATE users
SET email_verified_at = COALESCE(email_verified_at, now()), updated_at = now()
WHERE id = $1
RETURNING id, email, display_name, email_verified_at
`,
[tokenRow.user_id]
);
if (!user) {
throw statusError('验证链接无效或已过期', 400);
}
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
user.id
]);
return { message: '邮箱已验证', user: toPublicUser(user) };
});
}
export async function loginUser(payload: Record<string, unknown>) {
const email = cleanEmail(payload.email);
const password = cleanPassword(payload.password);
const user = await queryOne<LoginUserRow>(
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
[email]
);
if (!user || !(await verifyPassword(password, user.password_hash))) {
throw statusError('邮箱或密码不正确', 401);
}
if (!user.email_verified_at) {
throw statusError('请先完成邮箱验证', 403);
}
const sessionToken = createPlainToken();
await pool.query(
`
INSERT INTO user_sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + ($3 * interval '1 day'))
`,
[user.id, hashToken(sessionToken), sessionDays]
);
return { token: sessionToken, user: toPublicUser(user) };
}
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
if (token.length < 32) {
return null;
}
const user = await queryOne<UserRow>(
`
SELECT u.id, u.email, u.display_name, u.email_verified_at
FROM user_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.expires_at > now()
`,
[hashToken(token)]
);
return user ? toPublicUser(user) : null;
}
export async function logoutSession(token: string): Promise<void> {
if (token.length < 32) {
return;
}
await pool.query('DELETE FROM user_sessions WHERE token_hash = $1', [hashToken(token)]);
}

View File

@@ -1,5 +1,6 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail } from './auth.ts';
import { initializeDatabase, pool } from './db.ts';
import {
createConfig,
@@ -35,6 +36,7 @@ const app = Fastify({
});
await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: process.env.FRONTEND_ORIGIN ?? true
});
@@ -64,6 +66,39 @@ app.setErrorHandler(async (error, _request, reply) => {
app.get('/health', async () => ({ ok: true }));
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
}
app.post('/api/auth/register', async (request, reply) =>
reply.code(201).send(await registerUser(request.body as Record<string, unknown>))
);
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>));
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>));
app.get('/api/auth/me', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
return reply.code(401).send({ message: '请先登录' });
}
return { user };
});
app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
if (token) {
await logoutSession(token);
}
return reply.code(204).send();
});
app.get('/api/options', async () => getOptions());
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));

View File

@@ -22,6 +22,9 @@ services:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001
FRONTEND_ORIGIN: http://localhost:3000
APP_ORIGIN: http://localhost:3000
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
ports:
- "3001:3001"
depends_on:

View File

@@ -1,21 +1,80 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser } from './services/api';
const navItems = [
{ label: 'Pokemon', to: '/pokemon' },
{ label: '栖息地', to: '/habitats' },
{ label: '物品 / 材料单', to: '/items' },
{ label: '管理', to: '/admin' }
];
const router = useRouter();
const currentUser = ref<AuthUser | null>(null);
let removeAuthListener: (() => void) | null = null;
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
}
}
async function logout() {
try {
await api.logout();
} catch {
// The local session is cleared even when the server session is already gone.
}
currentUser.value = null;
setAuthToken(null);
await router.push('/pokemon');
}
onMounted(() => {
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
removeAuthListener?.();
});
</script>
<template>
<div class="app-shell">
<header class="topbar">
<RouterLink class="brand" to="/pokemon">Pokopia Wiki</RouterLink>
<nav class="nav-tabs" aria-label="主导航">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
{{ item.label }}
</RouterLink>
</nav>
<div class="topbar-main">
<RouterLink class="brand" to="/pokemon">Pokopia Wiki</RouterLink>
<nav class="nav-tabs" aria-label="主导航">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
{{ item.label }}
</RouterLink>
</nav>
</div>
<div class="auth-actions">
<template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
<button class="plain-button" type="button" @click="logout">退出</button>
</template>
<template v-else>
<RouterLink to="/login">登录</RouterLink>
<RouterLink to="/register">注册</RouterLink>
</template>
</div>
</header>
<main class="page">

View File

@@ -7,6 +7,9 @@ import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
export const router = createRouter({
history: createWebHistory(),
@@ -19,7 +22,10 @@ export const router = createRouter({
{ path: '/items', component: ItemsList },
{ path: '/items/:id', component: ItemDetail },
{ path: '/recipes/:id', component: RecipeDetail },
{ path: '/admin', component: AdminView }
{ path: '/admin', component: AdminView },
{ path: '/login', component: LoginView },
{ path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView }
],
scrollBehavior: () => ({ top: 0 })
});

View File

@@ -1,4 +1,6 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export interface NamedEntity {
id: number;
@@ -85,6 +87,27 @@ export interface Options {
maps: NamedEntity[];
}
export interface AuthUser {
id: number;
email: string;
displayName: string;
emailVerified: boolean;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload extends LoginPayload {
displayName: string;
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
export type ConfigType =
| 'skills'
| 'environments'
@@ -144,6 +167,38 @@ export function buildQuery(params: Record<string, string | number | undefined>):
return query ? `?${query}` : '';
}
export function getAuthToken(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(authTokenKey);
}
export function setAuthToken(token: string | null): void {
if (typeof localStorage === 'undefined') {
return;
}
if (token) {
localStorage.setItem(authTokenKey, token);
} else {
localStorage.removeItem(authTokenKey);
}
window.dispatchEvent(new Event(authChangeEvent));
}
export function onAuthTokenChange(callback: () => void): () => void {
window.addEventListener(authChangeEvent, callback);
return () => window.removeEventListener(authChangeEvent, callback);
}
function authHeaders(): HeadersInit {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function getErrorMessage(response: Response): Promise<string> {
try {
const data = (await response.json()) as { message?: unknown };
@@ -158,7 +213,9 @@ async function getErrorMessage(response: Response): Promise<string> {
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`);
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
@@ -171,7 +228,8 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
const response = await fetch(`${apiBaseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(body)
});
@@ -183,9 +241,21 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
return response.json() as Promise<T>;
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: authHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
}
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
@@ -194,6 +264,12 @@ async function deleteJson(path: string): Promise<void> {
}
export const api = {
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'),
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; subcategory?: string | null }) =>

View File

@@ -45,7 +45,15 @@ select {
backdrop-filter: blur(12px);
}
.topbar-main {
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
}
.brand {
flex: 0 0 auto;
font-size: 20px;
font-weight: 800;
color: #1d3b2b;
@@ -70,6 +78,38 @@ select {
color: #ffffff;
}
.auth-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.auth-actions a,
.auth-actions .plain-button {
min-height: 36px;
padding: 7px 12px;
border: 1px solid #c7c0b2;
border-radius: 8px;
background: #fffdfa;
color: #4e5c52;
font-weight: 800;
}
.auth-actions a.router-link-active {
border-color: #1f6f50;
color: #1f5c40;
}
.auth-user {
max-width: 180px;
overflow: hidden;
color: #566156;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.page {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
@@ -455,6 +495,21 @@ select {
font-weight: 800;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 40px;
padding: 8px 14px;
border: 0;
border-radius: 8px;
background: #1f6f50;
color: #ffffff;
font-weight: 800;
cursor: pointer;
}
.status {
padding: 18px;
border: 1px solid #d7d2c4;
@@ -486,6 +541,57 @@ select {
font-weight: 800;
}
.auth-page {
display: grid;
justify-items: center;
padding: 24px 0;
}
.auth-panel {
display: grid;
gap: 18px;
width: min(460px, 100%);
padding: 22px;
border: 1px solid #d7d2c4;
border-radius: 8px;
background: #ffffff;
}
.auth-panel .page-header {
margin-bottom: 0;
}
.auth-form {
display: grid;
gap: 14px;
}
.auth-message {
margin: 0;
padding: 10px 12px;
border: 1px solid #b9d8bd;
border-radius: 8px;
background: #edf7ef;
color: #1f5c40;
font-weight: 800;
}
.auth-message.error {
border-color: #e0b0ac;
background: #fff0ee;
color: #a83f39;
}
.auth-switch {
margin: 0;
color: #657067;
}
.auth-switch a {
color: #1f6f50;
font-weight: 800;
}
.admin-layout {
display: grid;
grid-template-columns: minmax(320px, 420px) 1fr;
@@ -569,6 +675,18 @@ button:disabled {
flex-direction: column;
}
.topbar-main {
align-items: start;
flex-direction: column;
gap: 12px;
width: 100%;
}
.auth-actions {
flex-wrap: wrap;
width: 100%;
}
.page-header {
align-items: start;
flex-direction: column;

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, setAuthToken } from '../services/api';
const route = useRoute();
const router = useRouter();
const email = ref('');
const password = ref('');
const busy = ref(false);
const errorMessage = ref('');
async function submitLogin() {
busy.value = true;
errorMessage.value = '';
try {
const response = await api.login({
email: email.value,
password: password.value
});
setAuthToken(response.token);
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/pokemon';
await router.push(redirect);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '登录失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">登录</h1>
<p class="page-subtitle">使用已验证邮箱进入 Pokopia Wiki</p>
</div>
</div>
<form class="auth-form" @submit.prevent="submitLogin">
<div class="field">
<label for="login-email">邮箱</label>
<input id="login-email" v-model="email" autocomplete="email" required type="email" />
</div>
<div class="field">
<label for="login-password">密码</label>
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
</div>
<p v-if="errorMessage" class="auth-message error">{{ errorMessage }}</p>
<button class="primary-button" :disabled="busy" type="submit">
{{ busy ? '登录中' : '登录' }}
</button>
</form>
<p class="auth-switch">
还没有账号
<RouterLink to="/register">注册</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue';
import { api } from '../services/api';
const email = ref('');
const displayName = ref('');
const password = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
async function submitRegister() {
busy.value = true;
message.value = '';
errorMessage.value = '';
try {
const response = await api.register({
email: email.value,
displayName: displayName.value,
password: password.value
});
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '注册失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">注册</h1>
<p class="page-subtitle">创建账号后需要完成邮箱验证</p>
</div>
</div>
<form class="auth-form" @submit.prevent="submitRegister">
<div class="field">
<label for="register-email">邮箱</label>
<input id="register-email" v-model="email" autocomplete="email" required type="email" />
</div>
<div class="field">
<label for="register-display-name">显示名</label>
<input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required />
</div>
<div class="field">
<label for="register-password">密码</label>
<input
id="register-password"
v-model="password"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<p v-if="message" class="auth-message">{{ message }}</p>
<p v-if="errorMessage" class="auth-message error">{{ errorMessage }}</p>
<button class="primary-button" :disabled="busy" type="submit">
{{ busy ? '发送中' : '发送验证邮件' }}
</button>
</form>
<p class="auth-switch">
已有账号
<RouterLink to="/login">登录</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../services/api';
const route = useRoute();
const busy = ref(true);
const message = ref('');
const errorMessage = ref('');
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) {
busy.value = false;
errorMessage.value = '验证链接无效或已过期';
return;
}
try {
const response = await api.verifyEmail(token);
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '邮箱验证失败';
} finally {
busy.value = false;
}
});
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">邮箱验证</h1>
<p class="page-subtitle">完成验证后即可登录</p>
</div>
</div>
<p v-if="busy" class="auth-message">正在验证邮箱</p>
<p v-else-if="message" class="auth-message">{{ message }}</p>
<p v-else class="auth-message error">{{ errorMessage }}</p>
<RouterLink class="primary-button" to="/login">去登录</RouterLink>
</div>
</section>
</template>