login+ai cluster

This commit is contained in:
stardrophere
2026-03-11 01:33:21 +08:00
parent 9fa07cfb07
commit 8ed819a580
39 changed files with 3392 additions and 610 deletions
+15 -76
View File
@@ -1,85 +1,24 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
<RouterView v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>
</RouterView>
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
<style>
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.logo {
display: block;
margin: 0 auto 2rem;
.page-fade-enter-from {
opacity: 0;
transform: translateX(-15px);
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
.page-fade-leave-to {
opacity: 0;
transform: translateX(15px);
}
</style>
+229 -24
View File
@@ -1,35 +1,240 @@
@import './base.css';
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
/* =========================================
1. 现代 SaaS 风格主题变量
========================================= */
:root {
/* 明亮模式 - 极简白与浅灰 */
--bg-base: #f8fafc;
--bg-surface: #ffffff;
--bg-input: #f1f5f9;
--border-subtle: #e2e8f0;
--border-strong: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-placeholder: #94a3b8;
--brand-primary: #4f46e5;
--brand-primary-hover: #4338ca;
--brand-primary-alpha: rgba(79, 70, 229, 0.1);
--status-error: #ef4444;
--status-success: #10b981;
/* 现代柔和扩散阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
}
html.dark {
/* 暗黑模式 - 深邃黑与暗石板色 */
--bg-base: #020617;
--bg-surface: #0f172a;
--bg-input: #1e293b;
--border-subtle: #1e293b;
--border-strong: #334155;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-placeholder: #475569;
--brand-primary: #6366f1;
--brand-primary-hover: #818cf8;
--brand-primary-alpha: rgba(99, 102, 241, 0.15);
--status-error: #f87171;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
}
/* =========================================
2. 全局重置与排版基准
========================================= */
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
font-family: 'Inter', 'Noto Sans SC', sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease, color 0.3s ease;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
min-height: 100vh;
background: transparent;
position: relative;
z-index: 1;
}
a,
.green {
a {
color: inherit;
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
button {
cursor: pointer;
border: none;
background: none;
font-family: inherit;
}
/* =========================================
高级背景环境光与数据网格
========================================= */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -2;
/* 绘制点阵网格 */
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
background-size: 24px 24px;
/* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
opacity: 0.4;
pointer-events: none;
}
html.dark body::before {
opacity: 0.15;
}
/* 极弱的雷达扫射呼吸环境光 */
body::after {
content: '';
position: fixed;
top: -50%;
left: -20%;
right: -20%;
height: 100vh;
z-index: -3;
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
opacity: 0.6;
pointer-events: none;
animation: ambient-pulse 8s ease-in-out infinite alternate;
}
@keyframes ambient-pulse {
0% {
transform: scale(1);
opacity: 0.4;
}
100% {
transform: scale(1.05);
opacity: 0.7;
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
/* =========================================
3. 现代表单控件体系
========================================= */
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.input-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-field {
width: 100%;
padding: 12px 14px;
background-color: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s ease;
}
.input-field::placeholder {
color: var(--text-placeholder);
}
.input-field:hover {
border-color: var(--border-strong);
}
.input-field:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
background-color: var(--bg-surface);
}
.input-action-btn {
position: absolute;
right: 12px;
font-size: 13px;
font-weight: 500;
color: var(--brand-primary);
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.input-action-btn:hover:not(:disabled) {
background: var(--brand-primary-alpha);
}
.input-action-btn:disabled {
color: var(--text-placeholder);
cursor: not-allowed;
}
.btn-primary {
width: 100%;
padding: 12px;
background-color: var(--brand-primary);
color: #ffffff;
font-size: 15px;
font-weight: 600;
border-radius: var(--radius-md);
transition: all 0.2s ease;
display: flex;
justify-content: center;
align-items: center;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--brand-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+80
View File
@@ -0,0 +1,80 @@
import type {
AuthTokenResponse,
LoginPayload,
LoginWithCodePayload,
MessageResponse,
RegisterPayload,
} from './auth.types'
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
type JsonValue = object | null
const MESSAGE_MAP: Record<string, string> = {
'Email is already registered': '该邮箱已注册',
'Email is not registered': '该邮箱未注册',
'Failed to send verification code': '验证码发送失败,请稍后重试',
'Verification code sent': '验证码已发送',
'Verification code does not exist or expired': '验证码不存在或已过期',
'Invalid verification code': '验证码错误',
'Invalid email or password': '邮箱或密码错误',
'Invalid email or verification code': '邮箱或验证码错误',
}
function localizeMessage(message: string): string {
return MESSAGE_MAP[message] ?? message
}
async function request<T>(path: string, payload: JsonValue): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: payload ? JSON.stringify(payload) : undefined,
})
const raw = await response.text()
let data: Record<string, unknown> = {}
if (raw) {
try {
data = JSON.parse(raw) as Record<string, unknown>
} catch {
data = {}
}
}
if (!response.ok) {
const detail = data.detail
if (typeof detail === 'string') {
throw new Error(localizeMessage(detail))
}
throw new Error('请求失败,请稍后重试')
}
if (typeof data.message === 'string') {
data.message = localizeMessage(data.message)
}
return data as T
}
export function sendRegisterCode(email: string): Promise<MessageResponse> {
return request<MessageResponse>('/auth/register/send-code', { email })
}
export function sendLoginCode(email: string): Promise<MessageResponse> {
return request<MessageResponse>('/auth/login/send-code', { email })
}
export function register(payload: RegisterPayload): Promise<AuthTokenResponse> {
return request<AuthTokenResponse>('/auth/register', payload)
}
export function login(payload: LoginPayload): Promise<AuthTokenResponse> {
return request<AuthTokenResponse>('/auth/login', payload)
}
export function loginWithCode(payload: LoginWithCodePayload): Promise<AuthTokenResponse> {
return request<AuthTokenResponse>('/auth/login/code', payload)
}
+36
View File
@@ -0,0 +1,36 @@
export interface UserProfile {
id: number
email: string
nickname: string | null
avatar_url: string | null
gender: string
created_at: string
}
export interface AuthTokenResponse {
access_token: string
token_type: string
expires_in: number
user: UserProfile
}
export interface MessageResponse {
message: string
}
export interface LoginPayload {
email: string
password: string
}
export interface LoginWithCodePayload {
email: string
verification_code: string
}
export interface RegisterPayload {
email: string
password: string
verification_code: string
nickname?: string
}
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="brand-logo-container">
<svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle
class="radar-ring outer"
cx="16"
cy="16"
r="14"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="4 8"
opacity="0.4"
/>
<circle
class="radar-ring inner"
cx="16"
cy="16"
r="9"
stroke="currentColor"
stroke-width="1.5"
stroke-dasharray="12 4"
opacity="0.6"
/>
<path
class="data-link"
d="M16 16 L25 7 M16 16 L7 22 L5 20 M16 16 L23 25"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<circle class="data-node" cx="25" cy="7" r="1.5" fill="currentColor" opacity="0.7" />
<circle class="data-node" cx="7" cy="22" r="1.5" fill="currentColor" opacity="0.7" />
<circle class="data-node" cx="23" cy="25" r="1" fill="currentColor" opacity="0.5" />
<circle class="ai-core" cx="16" cy="16" r="3.5" fill="currentColor" />
<circle class="ai-core-glow" cx="16" cy="16" r="3.5" fill="currentColor" opacity="0.4" />
</svg>
</div>
</template>
<style scoped>
.brand-logo-container {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
color: var(--brand-primary);
}
.insight-logo {
width: 100%;
height: 100%;
overflow: visible;
}
/* 核心呼吸灯动画 */
.ai-core-glow {
transform-origin: center;
animation: core-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 雷达圈旋转动画 */
.radar-ring {
transform-origin: center;
}
.radar-ring.outer {
animation: spin-reverse 20s linear infinite;
}
.radar-ring.inner {
animation: spin 12s linear infinite;
}
/* 数据连线光流效果 (通过 dashoffset 实现虚线流动) */
.data-link {
stroke-dasharray: 4;
animation: flow 3s linear infinite;
}
@keyframes core-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(2.2);
opacity: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
@keyframes flow {
from {
stroke-dashoffset: 8;
}
to {
stroke-dashoffset: 0;
}
}
</style>
-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const isAnimating = ref(false)
function handleToggle(event: MouseEvent) {
const root = document.documentElement
root.style.setProperty('--theme-flash-x', `${event.clientX}px`)
root.style.setProperty('--theme-flash-y', `${event.clientY}px`)
isAnimating.value = true
themeStore.toggleTheme()
window.setTimeout(() => {
isAnimating.value = false
}, 520)
}
</script>
<template>
<button
class="theme-toggle"
:class="{ 'is-dark': themeStore.isDark, 'is-animating': isAnimating }"
type="button"
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
@click="handleToggle"
>
<span class="toggle-track">
<span class="track-glow"></span>
<span class="track-stars"></span>
<span class="spark-layer">
<span class="spark"></span>
<span class="spark"></span>
<span class="spark"></span>
</span>
<span class="toggle-thumb">
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 5.25a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V6a.75.75 0 0 1 .75-.75ZM7.227 7.227a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 1 1-1.06-1.06l1.06-1.06ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm-6.75 3a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Zm11.25 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75Zm-8.213 3.653a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm7.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06-1.06l1.06-1.06ZM12 16.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 1 1-1.5 0v-1.5a.75.75 0 0 1 .75-.75Z"
/>
</svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M14.5 2.25a.75.75 0 0 1 .604 1.195 7.95 7.95 0 0 0 4.6 12.395.75.75 0 0 1 .194 1.387 10.5 10.5 0 1 1-6.592-15.052.75.75 0 0 1 .194 1.387 8.954 8.954 0 0 0-1.75 16.693 9.002 9.002 0 0 0 6.074-2.04 9.45 9.45 0 0 1-4.822-8.253 9.44 9.44 0 0 1 1.305-4.82.75.75 0 0 1 .194-1.202Z"
/>
</svg>
</span>
</span>
<span class="toggle-text">{{ themeStore.isDark ? '浅色模式' : '暗黑模式' }}</span>
</button>
</template>
<style scoped>
.theme-toggle {
border: 1px solid var(--surface-border);
background: var(--surface);
color: var(--text);
border-radius: 14px;
padding: 6px 10px 6px 8px;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
}
.theme-toggle:hover {
transform: translateY(-1px);
border-color: var(--primary);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 24%, transparent);
}
.theme-toggle:active {
transform: translateY(0) scale(0.98);
}
.toggle-track {
width: 58px;
height: 32px;
border-radius: 999px;
background: linear-gradient(120deg, #d4e4ff, #b2c6ff);
border: 1px solid rgba(255, 255, 255, 0.45);
padding: 3px;
position: relative;
overflow: hidden;
transition: background 360ms ease;
}
.theme-toggle.is-dark .toggle-track {
background: linear-gradient(120deg, #0f1731, #1f2e62);
}
.track-glow {
position: absolute;
width: 20px;
height: 20px;
border-radius: 999px;
left: 8px;
top: 5px;
background: rgba(255, 244, 170, 0.75);
filter: blur(2px);
opacity: 0.85;
transition: left 360ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease, background 300ms ease;
}
.theme-toggle.is-dark .track-glow {
left: 30px;
opacity: 0.4;
background: rgba(166, 195, 255, 0.7);
}
.track-stars {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 260ms ease;
}
.track-stars::before,
.track-stars::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 999px;
background: rgba(226, 235, 255, 0.95);
}
.track-stars::before {
left: 16px;
top: 9px;
box-shadow: 16px 6px 0 rgba(226, 235, 255, 0.75), 22px -2px 0 rgba(226, 235, 255, 0.55);
}
.track-stars::after {
left: 28px;
top: 18px;
box-shadow: -12px 5px 0 rgba(226, 235, 255, 0.65), 8px -8px 0 rgba(226, 235, 255, 0.75);
}
.theme-toggle.is-dark .track-stars {
opacity: 1;
}
.spark-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.spark {
position: absolute;
width: 4px;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
opacity: 0;
}
.spark:nth-child(1) {
left: 22px;
top: 14px;
}
.spark:nth-child(2) {
left: 30px;
top: 11px;
}
.spark:nth-child(3) {
left: 26px;
top: 19px;
}
.theme-toggle.is-animating .spark:nth-child(1) {
animation: spark-burst-1 480ms ease-out;
}
.theme-toggle.is-animating .spark:nth-child(2) {
animation: spark-burst-2 480ms ease-out;
}
.theme-toggle.is-animating .spark:nth-child(3) {
animation: spark-burst-3 480ms ease-out;
}
.toggle-thumb {
width: 24px;
height: 24px;
border-radius: 999px;
background: linear-gradient(145deg, #ffffff, #f0f4ff);
box-shadow: 0 3px 10px rgba(37, 49, 89, 0.26);
position: relative;
transform: translateX(0);
transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1), background 320ms ease;
overflow: hidden;
}
.theme-toggle.is-dark .toggle-thumb {
transform: translateX(26px);
background: linear-gradient(145deg, #d5e0ff, #b7c9ff);
}
.icon {
position: absolute;
inset: 5px;
width: 14px;
height: 14px;
fill: #526ab0;
transition: opacity 240ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.icon-sun {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.icon-moon {
opacity: 0;
transform: rotate(-40deg) scale(0.45);
}
.theme-toggle.is-dark .icon-sun {
opacity: 0;
transform: rotate(70deg) scale(0.4);
}
.theme-toggle.is-dark .icon-moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.toggle-text {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text);
}
.theme-toggle::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 36%, transparent);
left: 34px;
top: 14px;
transform: scale(0);
opacity: 0;
pointer-events: none;
}
.theme-toggle.is-animating::after {
animation: click-ripple 520ms ease-out;
}
.theme-toggle.is-animating .toggle-thumb {
animation: thumb-pop 520ms cubic-bezier(0.22, 1, 0.36, 1);
}
.theme-toggle.is-dark.is-animating .toggle-thumb {
animation-name: thumb-pop-dark;
}
.theme-toggle.is-animating .toggle-track {
animation: track-flare 520ms ease;
}
@keyframes click-ripple {
0% {
transform: scale(0.2);
opacity: 0.5;
}
100% {
transform: scale(4.2);
opacity: 0;
}
}
@keyframes thumb-pop {
0% {
transform: translateX(0) scale(1);
}
40% {
transform: translateX(13px) scale(1.1);
}
100% {
transform: translateX(26px) scale(1);
}
}
@keyframes thumb-pop-dark {
0% {
transform: translateX(26px) scale(1);
}
40% {
transform: translateX(13px) scale(1.1);
}
100% {
transform: translateX(0) scale(1);
}
}
@keyframes track-flare {
0% {
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
}
45% {
box-shadow: 0 0 16px rgba(255, 255, 255, 0.45);
}
100% {
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
}
}
@keyframes spark-burst-1 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(-12px, -8px) scale(1.1);
opacity: 0;
}
}
@keyframes spark-burst-2 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(10px, -10px) scale(1.15);
opacity: 0;
}
}
@keyframes spark-burst-3 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(2px, 12px) scale(1.2);
opacity: 0;
}
}
@media (max-width: 620px) {
.toggle-text {
display: none;
}
.theme-toggle {
padding-right: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle,
.toggle-track,
.toggle-thumb,
.track-glow,
.icon {
transition: none;
}
.theme-toggle.is-animating::after,
.theme-toggle.is-animating .toggle-thumb,
.theme-toggle.is-dark.is-animating .toggle-thumb,
.theme-toggle.is-animating .toggle-track,
.theme-toggle.is-animating .spark {
animation: none;
}
}
</style>
-87
View File
@@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
+6 -2
View File
@@ -1,14 +1,18 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
import { useThemeStore } from './stores/theme'
const app = createApp(App)
app.use(createPinia())
app.use(pinia)
const themeStore = useThemeStore(pinia)
themeStore.initTheme()
app.use(router)
app.mount('#app')
+43 -7
View File
@@ -1,5 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { pinia } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import RegisterView from '@/views/RegisterView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -8,16 +13,47 @@ const router = createRouter({
path: '/',
name: 'home',
component: HomeView,
meta: {
requiresAuth: true,
},
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
path: '/login',
name: 'login',
component: LoginView,
meta: {
guestOnly: true,
},
},
{
path: '/register',
name: 'register',
component: RegisterView,
meta: {
guestOnly: true,
},
},
],
})
router.beforeEach((to) => {
const authStore = useAuthStore(pinia)
authStore.restore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
}
if (to.meta.guestOnly && authStore.isAuthenticated) {
return { name: 'home' }
}
return true
})
export default router
+8
View File
@@ -0,0 +1,8 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
guestOnly?: boolean
}
}
+164
View File
@@ -0,0 +1,164 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { login, loginWithCode, register, sendLoginCode, sendRegisterCode } from '@/auth.api'
import type { LoginPayload, LoginWithCodePayload, RegisterPayload, UserProfile } from '@/auth.types'
interface PersistedAuthState {
accessToken: string
expiresAt: number
user: UserProfile
}
const AUTH_STORAGE_KEY = 'insight-radar-auth'
function loadPersistedState(): PersistedAuthState | null {
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as PersistedAuthState
if (!parsed.accessToken || !parsed.expiresAt || !parsed.user) {
return null
}
return parsed
} catch {
return null
}
}
export const useAuthStore = defineStore('auth', () => {
const persisted = loadPersistedState()
const accessToken = ref<string | null>(persisted?.accessToken ?? null)
const expiresAt = ref<number | null>(persisted?.expiresAt ?? null)
const user = ref<UserProfile | null>(persisted?.user ?? null)
const loading = ref(false)
const isExpired = computed(() => {
if (!expiresAt.value) {
return true
}
return Date.now() >= expiresAt.value
})
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value && !isExpired.value))
function persist() {
if (!accessToken.value || !expiresAt.value || !user.value) {
localStorage.removeItem(AUTH_STORAGE_KEY)
return
}
const payload: PersistedAuthState = {
accessToken: accessToken.value,
expiresAt: expiresAt.value,
user: user.value,
}
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(payload))
}
function setSession(params: { accessToken: string; expiresIn: number; user: UserProfile }) {
accessToken.value = params.accessToken
expiresAt.value = Date.now() + params.expiresIn * 1000
user.value = params.user
persist()
}
function clearSession() {
accessToken.value = null
expiresAt.value = null
user.value = null
persist()
}
async function loginWithPassword(payload: LoginPayload) {
loading.value = true
try {
const data = await login(payload)
setSession({
accessToken: data.access_token,
expiresIn: data.expires_in,
user: data.user,
})
return data
} finally {
loading.value = false
}
}
async function loginWithVerificationCode(payload: LoginWithCodePayload) {
loading.value = true
try {
const data = await loginWithCode(payload)
setSession({
accessToken: data.access_token,
expiresIn: data.expires_in,
user: data.user,
})
return data
} finally {
loading.value = false
}
}
async function registerAccount(payload: RegisterPayload) {
loading.value = true
try {
const data = await register(payload)
setSession({
accessToken: data.access_token,
expiresIn: data.expires_in,
user: data.user,
})
return data
} finally {
loading.value = false
}
}
async function sendCode(email: string) {
loading.value = true
try {
return await sendRegisterCode(email)
} finally {
loading.value = false
}
}
async function sendLoginVerificationCode(email: string) {
loading.value = true
try {
return await sendLoginCode(email)
} finally {
loading.value = false
}
}
function restore() {
if (isExpired.value) {
clearSession()
}
}
function logout() {
clearSession()
}
return {
accessToken,
expiresAt,
user,
loading,
isAuthenticated,
loginWithPassword,
loginWithVerificationCode,
registerAccount,
sendCode,
sendLoginVerificationCode,
restore,
logout,
}
})
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+66
View File
@@ -0,0 +1,66 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
type ThemeMode = 'dark' | 'light'
const THEME_STORAGE_KEY = 'insight-radar-theme'
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>('light')
const initialized = ref(false)
let transitionTimer: number | null = null
const isDark = computed(() => mode.value === 'dark')
function applyTheme(nextMode: ThemeMode) {
mode.value = nextMode
document.documentElement.classList.toggle('dark', nextMode === 'dark')
localStorage.setItem(THEME_STORAGE_KEY, nextMode)
}
function runTransition(nextMode: ThemeMode) {
const root = document.documentElement
root.dataset.themeTransition = nextMode === 'dark' ? 'to-dark' : 'to-light'
root.classList.add('theme-switching')
if (transitionTimer) {
window.clearTimeout(transitionTimer)
}
transitionTimer = window.setTimeout(() => {
root.classList.remove('theme-switching')
delete root.dataset.themeTransition
transitionTimer = null
}, 760)
}
function initTheme() {
if (initialized.value) {
return
}
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
if (savedTheme === 'dark' || savedTheme === 'light') {
applyTheme(savedTheme)
initialized.value = true
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
applyTheme(prefersDark ? 'dark' : 'light')
initialized.value = true
}
function toggleTheme() {
const nextMode: ThemeMode = isDark.value ? 'light' : 'dark'
runTransition(nextMode)
applyTheme(nextMode)
}
return {
mode,
isDark,
initTheme,
toggleTheme,
}
})
+295 -4
View File
@@ -1,9 +1,300 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
import BrandLogo from '@/components/BrandLogo.vue'
const authStore = useAuthStore()
const router = useRouter()
const displayName = computed(() => authStore.user?.nickname || authStore.user?.email || '用户')
const tokenExpiryText = computed(() => {
if (!authStore.expiresAt) {
return '未知'
}
return new Date(authStore.expiresAt).toLocaleString('zh-CN')
})
async function handleLogout() {
authStore.logout()
await router.replace('/login')
}
</script>
<template>
<main>
<TheWelcome />
</main>
<div class="dashboard-layout">
<header class="top-nav">
<div class="nav-container">
<div class="nav-brand">
<div class="logo">
<BrandLogo />
InsightRadar
</div>
</div>
<div class="nav-actions">
<ThemeToggle />
<div class="divider"></div>
<button class="btn-ghost" type="button" @click="handleLogout">退出登录</button>
</div>
</div>
</header>
<main class="dashboard-main">
<div class="page-header">
<h1>概览</h1>
<p>欢迎回来{{ displayName }}这里是您的全局事件中心</p>
</div>
<div class="bento-grid">
<article class="bento-card">
<div class="card-icon">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<p class="card-label">当前账户</p>
<h2 class="card-value">{{ displayName }}</h2>
<p class="card-meta">{{ authStore.user?.email }}</p>
</article>
<article class="bento-card">
<div class="card-icon" :class="authStore.isAuthenticated ? 'text-success' : ''">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<p class="card-label">会话状态</p>
<h2 class="card-value">{{ authStore.isAuthenticated ? '安全连接中' : '未登录' }}</h2>
<p class="card-meta">有效期至{{ tokenExpiryText }}</p>
</article>
<article class="bento-card col-span-full feature-card">
<div class="feature-content">
<p class="card-label">开发者接入</p>
<h2 class="card-value">认证体系已就绪</h2>
<p class="card-meta">在请求您的业务接口时请在 Headers 中携带如下凭证</p>
<div class="code-snippet">
<code>Authorization: Bearer {token}</code>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<style scoped>
.dashboard-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶部导航 */
.top-nav {
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 10;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 16px;
}
.logo-dot {
width: 10px;
height: 10px;
background: var(--brand-primary);
border-radius: 3px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 23px;
font-weight: 700;
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.divider {
width: 1px;
height: 20px;
background-color: var(--border-strong);
}
.btn-ghost {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: var(--radius-md);
transition: all 0.2s;
}
.btn-ghost:hover {
background-color: var(--bg-input);
color: var(--text-primary);
}
/* 主内容区 */
.dashboard-main {
flex: 1;
max-width: 1000px;
margin: 0 auto;
width: 100%;
padding: 48px 24px;
}
.page-header {
margin-bottom: 40px;
}
.page-header h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 8px 0;
letter-spacing: -0.02em;
}
.page-header p {
font-size: 16px;
color: var(--text-secondary);
margin: 0;
}
/* Bento Grid */
.bento-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 20px;
}
@media (min-width: 768px) {
.bento-grid {
grid-template-columns: repeat(2, 1fr);
}
.col-span-full {
grid-column: 1 / -1;
}
}
.bento-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl);
padding: 24px;
box-shadow: var(--shadow-sm);
transition:
box-shadow 0.3s ease,
border-color 0.3s ease;
}
.bento-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--border-strong);
}
.card-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-lg);
background-color: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
color: var(--text-secondary);
}
.card-icon svg {
width: 20px;
height: 20px;
}
.text-success {
color: var(--status-success);
background-color: rgba(16, 185, 129, 0.1);
}
.card-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 8px 0;
}
.card-value {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--text-primary);
}
.card-meta {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
/* 强调卡片 */
.feature-card {
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--brand-primary), #818cf8);
}
.code-snippet {
margin-top: 16px;
background-color: var(--bg-input);
border: 1px solid var(--border-subtle);
padding: 12px 16px;
border-radius: var(--radius-md);
font-family: monospace;
font-size: 14px;
color: var(--brand-primary);
}
</style>
+447
View File
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
type LoginMode = 'password' | 'code'
const CODE_RESEND_SECONDS = 60
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const loginMode = ref<LoginMode>('password')
const showPassword = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const countdown = ref(0)
const form = reactive({
email: '',
password: '',
verificationCode: '',
})
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSendCode = computed(() => {
if (authStore.loading || countdown.value > 0) {
return false
}
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
})
watch(loginMode, () => {
errorMessage.value = ''
successMessage.value = ''
})
function startCooldown() {
countdown.value = CODE_RESEND_SECONDS
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
function validateForm(): string {
if (!form.email.trim()) {
return '请输入邮箱'
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) {
return '邮箱格式不正确'
}
if (loginMode.value === 'password') {
if (form.password.length < 8) {
return '密码长度至少 8 位'
}
} else if (!/^\d{6}$/.test(form.verificationCode)) {
return '验证码必须为 6 位数字'
}
return ''
}
async function handleSendLoginCode() {
errorMessage.value = ''
successMessage.value = ''
if (!canSendCode.value) {
errorMessage.value = '请先输入有效邮箱'
return
}
try {
const result = await authStore.sendLoginVerificationCode(form.email.trim())
successMessage.value = result.message || '验证码已发送'
startCooldown()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
}
}
async function handleSubmit() {
errorMessage.value = ''
successMessage.value = ''
const validationError = validateForm()
if (validationError) {
errorMessage.value = validationError
return
}
try {
if (loginMode.value === 'password') {
await authStore.loginWithPassword({
email: form.email.trim(),
password: form.password,
})
} else {
await authStore.loginWithVerificationCode({
email: form.email.trim(),
verification_code: form.verificationCode,
})
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
}
}
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<template>
<main class="split-layout">
<aside class="brand-panel">
<div class="brand-content">
<div class="logo">
<BrandLogo />
InsightRadar
</div>
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
<p class="brand-desc">
聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录
</p>
</div>
<div class="ambient-glow"></div>
</aside>
<section class="form-panel">
<div class="top-actions">
<ThemeToggle />
</div>
<div class="form-container">
<div class="form-header">
<h2>欢迎回来</h2>
<p>登录后继续查看 InsightRadar 实时动态</p>
</div>
<div class="login-mode-tabs">
<button
class="mode-btn"
:class="{ active: loginMode === 'password' }"
type="button"
@click="loginMode = 'password'"
>
密码登录
</button>
<button
class="mode-btn"
:class="{ active: loginMode === 'code' }"
type="button"
@click="loginMode = 'code'"
>
邮箱验证码登录
</button>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="input-group">
<label class="input-label" for="email">邮箱地址</label>
<input
id="email"
v-model.trim="form.email"
class="input-field"
type="email"
placeholder="hello@example.com"
autocomplete="email"
/>
</div>
<div v-if="loginMode === 'password'" class="input-group">
<label class="input-label" for="password">密码</label>
<div class="input-wrapper">
<input
id="password"
v-model="form.password"
class="input-field"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
autocomplete="current-password"
/>
<button type="button" class="input-action-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
</div>
<div v-else class="input-group">
<label class="input-label" for="verification-code">验证码</label>
<div class="input-wrapper code-wrapper">
<input
id="verification-code"
v-model.trim="form.verificationCode"
class="input-field"
type="text"
maxlength="6"
placeholder="请输入 6 位验证码"
inputmode="numeric"
/>
<button type="button" class="input-action-btn" :disabled="!canSendCode" @click="handleSendLoginCode">
{{ countdown > 0 ? `${countdown}s` : '发送验证码' }}
</button>
</div>
</div>
<div v-if="errorMessage" class="message error-msg">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
{{ errorMessage }}
</div>
<div v-if="successMessage" class="message success-msg">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 10-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
{{ successMessage }}
</div>
<button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ authStore.loading ? '登录中...' : loginMode === 'password' ? '密码登录' : '邮箱验证码登录' }}
</button>
</form>
<p class="form-footer">
还没有账号
<RouterLink to="/register" class="link">立即注册</RouterLink>
</p>
</div>
</section>
</main>
</template>
<style scoped>
.split-layout {
display: flex;
min-height: 100vh;
}
.brand-panel {
flex: 1;
display: none;
background-color: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
padding: 60px;
position: relative;
overflow: hidden;
}
@media (min-width: 900px) {
.brand-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
}
.brand-content {
position: relative;
z-index: 2;
max-width: 480px;
}
.logo {
display: flex;
align-items: center;
gap: 16px;
font-size: 26px;
font-weight: 700;
margin-bottom: 60px;
}
.brand-title {
font-size: 40px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 24px;
}
.brand-desc {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
}
.ambient-glow {
position: absolute;
top: 50%;
right: -20%;
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
transform: translateY(-50%);
filter: blur(60px);
z-index: 1;
pointer-events: none;
}
.form-panel {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.top-actions {
position: absolute;
top: 24px;
right: 24px;
}
.form-container {
margin: auto;
width: 100%;
max-width: 420px;
padding: 40px 24px;
}
.form-header {
margin-bottom: 24px;
}
.form-header h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
letter-spacing: -0.01em;
}
.form-header p {
color: var(--text-secondary);
margin: 0;
font-size: 15px;
}
.login-mode-tabs {
margin-bottom: 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mode-btn {
height: 40px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: var(--bg-input);
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
}
.mode-btn.active {
border-color: var(--brand-primary);
background: var(--brand-primary-alpha);
color: var(--brand-primary);
}
.mode-btn:hover {
border-color: var(--border-strong);
}
.auth-form {
margin-top: 4px;
}
.code-wrapper .input-field {
padding-right: 120px;
}
.message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: var(--radius-md);
font-size: 14px;
margin-bottom: 14px;
}
.message svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.error-msg {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.success-msg {
background-color: rgba(16, 185, 129, 0.1);
color: var(--status-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.form-footer {
margin-top: 32px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.link {
color: var(--brand-primary);
font-weight: 500;
margin-left: 4px;
transition: opacity 0.2s;
}
.link:hover {
opacity: 0.8;
}
</style>
+414
View File
@@ -0,0 +1,414 @@
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
import BrandLogo from '@/components/BrandLogo.vue'
const CODE_RESEND_SECONDS = 60
const authStore = useAuthStore()
const router = useRouter()
const form = reactive({
email: '',
nickname: '',
verificationCode: '',
password: '',
confirmPassword: '',
})
const showPassword = ref(false)
const showConfirmPassword = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSendCode = computed(() => {
if (authStore.loading || countdown.value > 0) return false
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
})
// === 新增:密码强度计算逻辑 ===
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return 0
let score = 0
if (pwd.length >= 8) score += 1 // 长度达标
if (/[A-Z]/.test(pwd) || /[a-z]/.test(pwd)) score += 1 // 包含字母
if (/[0-9]/.test(pwd)) score += 1 // 包含数字
if (/[^A-Za-z0-9]/.test(pwd)) score += 1 // 包含特殊字符
return score
})
const strengthLabels = ['极弱', '弱', '中等', '强', '极强']
const strengthColor = computed(() => {
const colors = ['var(--muted)', '#db3a5e', '#f59e0b', '#2e9f5f', '#10b981']
return colors[passwordStrength.value]
})
// ==========================
function startCooldown() {
countdown.value = CODE_RESEND_SECONDS
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
async function handleSendCode() {
errorMessage.value = ''
successMessage.value = ''
if (!canSendCode.value) {
errorMessage.value = '请先输入有效邮箱'
return
}
try {
const result = await authStore.sendCode(form.email.trim())
successMessage.value = result.message || '验证码已发送'
startCooldown()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
}
}
function validateForm() {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) return '请输入有效邮箱'
if (!/^\d{6}$/.test(form.verificationCode)) return '验证码必须为 6 位数字'
if (form.password.length < 8) return '密码长度至少 8 位'
if (form.password !== form.confirmPassword) return '两次密码输入不一致'
return ''
}
async function handleSubmit() {
errorMessage.value = validateForm()
successMessage.value = ''
if (errorMessage.value) return
try {
await authStore.registerAccount({
email: form.email.trim(),
password: form.password,
verification_code: form.verificationCode,
nickname: form.nickname.trim() || undefined,
})
await router.replace('/')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '注册失败,请稍后重试'
}
}
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<template>
<main class="split-layout">
<aside class="brand-panel">
<div class="brand-content">
<div class="logo">
<BrandLogo />
InsightRadar
</div>
<h1 class="brand-title">开启智能<br />分析之旅</h1>
<p class="brand-desc">
只需几秒钟即可创建您的专属账号体验下一代全网事件聚合与 AI 洞察服务
</p>
</div>
<div class="ambient-glow"></div>
</aside>
<section class="form-panel">
<div class="top-actions">
<ThemeToggle />
</div>
<div class="form-container">
<div class="form-header">
<h2>创建账号</h2>
<p>填写以下信息完成注册</p>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="input-group">
<label class="input-label" for="email">邮箱地址</label>
<input
id="email"
v-model.trim="form.email"
class="input-field"
type="email"
placeholder="hello@example.com"
/>
</div>
<div class="input-group">
<label class="input-label" for="verification-code">验证码</label>
<div class="input-wrapper">
<input
id="verification-code"
v-model.trim="form.verificationCode"
class="input-field"
type="text"
maxlength="6"
placeholder="6位数字"
/>
<button
type="button"
class="input-action-btn"
:disabled="!canSendCode"
@click="handleSendCode"
>
{{ countdown > 0 ? `${countdown}s 后重发` : '获取验证码' }}
</button>
</div>
</div>
<div class="input-group" style="margin-bottom: 12px">
<label class="input-label" for="password">设置密码</label>
<div class="input-wrapper">
<input
id="password"
v-model="form.password"
class="input-field"
:type="showPassword ? 'text' : 'password'"
placeholder="至少 8 位字符"
/>
<button type="button" class="input-action-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
</div>
<div class="pwd-strength" v-if="form.password.length > 0">
<div class="strength-segments">
<div
v-for="n in 4"
:key="n"
class="segment"
:style="{
backgroundColor: passwordStrength >= n ? strengthColor : 'var(--border-subtle)',
}"
></div>
</div>
<span class="strength-label" :style="{ color: strengthColor }">{{
strengthLabels[passwordStrength]
}}</span>
</div>
<div class="input-group">
<label class="input-label" for="confirm-password">确认密码</label>
<div class="input-wrapper">
<input
id="confirm-password"
v-model="form.confirmPassword"
class="input-field"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
/>
</div>
</div>
<div v-if="errorMessage" class="message error-msg">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="message success-msg">
{{ successMessage }}
</div>
<button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ authStore.loading ? '提交中...' : '注册并登录' }}
</button>
</form>
<p class="form-footer">
已有账号
<RouterLink to="/login" class="link">直接登录</RouterLink>
</p>
</div>
</section>
</main>
</template>
<style scoped>
/* 大部分样式复用 Login 的 split-layout 体系 */
.split-layout {
display: flex;
min-height: 100vh;
}
.brand-panel {
flex: 1;
display: none;
background-color: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
padding: 60px;
position: relative;
overflow: hidden;
}
@media (min-width: 900px) {
.brand-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
}
.brand-content {
position: relative;
z-index: 2;
max-width: 480px;
}
.logo {
display: flex;
align-items: center;
gap: 16px;
font-size: 26px;
font-weight: 700;
margin-bottom: 60px;
}
.logo-dot {
width: 14px;
height: 14px;
background: var(--brand-primary);
border-radius: 4px;
}
.brand-title {
font-size: 40px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 24px;
}
.brand-desc {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
}
.ambient-glow {
position: absolute;
top: 60%;
left: -10%;
width: 500px;
height: 500px;
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
transform: translateY(-50%);
filter: blur(50px);
z-index: 1;
pointer-events: none;
}
.form-panel {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
.top-actions {
position: absolute;
top: 24px;
right: 24px;
}
.form-container {
margin: auto;
width: 100%;
max-width: 400px;
padding: 40px 24px;
}
.form-header {
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.form-header p {
color: var(--text-secondary);
margin: 0;
font-size: 15px;
}
/* 现代密码强度条 */
.pwd-strength {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.strength-segments {
flex: 1;
display: flex;
gap: 4px;
}
.segment {
height: 4px;
flex: 1;
border-radius: 2px;
transition: background-color 0.3s ease;
}
.strength-label {
font-size: 12px;
font-weight: 600;
width: 32px;
text-align: right;
transition: color 0.3s ease;
}
.message {
padding: 12px;
border-radius: var(--radius-md);
font-size: 14px;
margin-bottom: 20px;
}
.error-msg {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.success-msg {
background-color: rgba(16, 185, 129, 0.1);
color: var(--status-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.form-footer {
margin-top: 32px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.link {
color: var(--brand-primary);
font-weight: 500;
margin-left: 4px;
}
</style>