This commit is contained in:
aaron 2025-05-06 16:37:57 +08:00
parent ec76fe077f
commit 999132c790
10 changed files with 1698 additions and 402 deletions

View File

@ -7,7 +7,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Crypto AI - 加密货币AI服务平台</title>
<title>Crypto.AI - AI Agent for Web3</title>
<style>
html,
body {

View File

@ -1,21 +1,91 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useUserStore } from './stores/user'
const mobileMenuOpen = ref(false)
const userStore = useUserStore()
const isAuthenticated = computed(() => userStore.isAuthenticated)
const userInfo = computed(() => userStore.userInfo)
//
const userLevelName = computed(() => {
if (!userInfo.value) return ''
switch (userInfo.value.level) {
case 0:
return '普通会员'
case 1:
return 'VIP'
case 2:
return '超级VIP'
default:
return '普通会员'
}
})
//
const userLevelClass = computed(() => {
if (!userInfo.value) return ''
switch (userInfo.value.level) {
case 0:
return 'user-level-normal'
case 1:
return 'user-level-vip'
case 2:
return 'user-level-super-vip'
default:
return 'user-level-normal'
}
})
const toggleMobileMenu = () => {
mobileMenuOpen.value = !mobileMenuOpen.value
}
const closeMobileMenu = () => {
mobileMenuOpen.value = false
}
const handleLogout = () => {
userStore.logout()
mobileMenuOpen.value = false
//
if (window.location.pathname.includes('/user')) {
window.location.href = '/'
}
}
</script>
<template>
<div class="app-container">
<header class="app-header">
<div class="header-content">
<!-- 左侧Logo -->
<div class="logo">
<img src="@/assets/logo.png" alt="Crypto.AI Logo" class="logo-image" />
Crypto.AI
<RouterLink to="/" class="logo-link">
<img src="@/assets/logo.png" alt="Crypto.AI Logo" class="logo-image" />
Crypto.AI
</RouterLink>
</div>
<!-- 右侧用户区域 - 仅在桌面版显示 -->
<div class="user-area">
<!-- 未登录状态显示登录/注册链接 -->
<template v-if="!isAuthenticated">
<RouterLink to="/login" class="auth-link">登录</RouterLink>
<RouterLink to="/register" class="auth-link register-link">注册</RouterLink>
</template>
<!-- 已登录状态显示用户信息和退出按钮 -->
<template v-else>
<div class="user-menu">
<div class="user-info">
<span class="user-nickname">{{ userInfo?.nickname }}</span>
<span class="user-level" :class="userLevelClass">{{ userLevelName }}</span>
</div>
<button class="logout-button" @click="handleLogout">退出</button>
</div>
</template>
</div>
<!-- 汉堡菜单按钮 -->
@ -24,20 +94,52 @@ const toggleMobileMenu = () => {
<span class="bar" :class="{ 'bar-2-active': mobileMenuOpen }"></span>
<span class="bar" :class="{ 'bar-3-active': mobileMenuOpen }"></span>
</button>
<nav class="main-nav" :class="{ 'mobile-menu-open': mobileMenuOpen }">
<RouterLink to="/" class="nav-link" @click="mobileMenuOpen = false">首页</RouterLink>
<RouterLink to="/ai-feed" class="nav-link" @click="mobileMenuOpen = false"
>AI分析</RouterLink
>
<!-- <RouterLink to="/ai-agent" class="nav-link">AI Agent</RouterLink> -->
<!-- <RouterLink to="/tools" class="nav-link">工具集合</RouterLink> -->
</nav>
</div>
</header>
<!-- 移动菜单 -->
<div class="mobile-menu" :class="{ 'mobile-menu-open': mobileMenuOpen }">
<nav class="mobile-nav">
<RouterLink to="/" class="mobile-nav-link" @click="closeMobileMenu">首页</RouterLink>
<RouterLink to="/ai-agent" class="mobile-nav-link" @click="closeMobileMenu"
>AI Agent</RouterLink
>
<RouterLink to="/ai-feed" class="mobile-nav-link" @click="closeMobileMenu"
>AI分析</RouterLink
>
<!-- <RouterLink to="/ai-agent" class="mobile-nav-link" @click="closeMobileMenu">AI Agent</RouterLink> -->
<!-- <RouterLink to="/tools" class="mobile-nav-link" @click="closeMobileMenu">工具集合</RouterLink> -->
</nav>
<div class="mobile-user-area">
<!-- 未登录状态显示登录/注册链接 -->
<template v-if="!isAuthenticated">
<RouterLink to="/login" class="mobile-auth-link" @click="closeMobileMenu"
>登录</RouterLink
>
<RouterLink
to="/register"
class="mobile-auth-link mobile-register-link"
@click="closeMobileMenu"
>注册</RouterLink
>
</template>
<!-- 已登录状态显示用户信息和退出按钮 -->
<template v-else>
<div class="mobile-user-info">
<span class="mobile-user-nickname">{{ userInfo?.nickname }}</span>
<span class="user-level mobile-user-level" :class="userLevelClass">{{
userLevelName
}}</span>
</div>
<button class="mobile-logout-button" @click="handleLogout">退出</button>
</template>
</div>
</div>
<!-- 遮罩层 - 仅在小屏幕上显示 -->
<div v-if="mobileMenuOpen" class="mobile-menu-overlay" @click="mobileMenuOpen = false"></div>
<div v-if="mobileMenuOpen" class="mobile-menu-overlay" @click="closeMobileMenu"></div>
<main class="app-content">
<div class="content-container">
@ -58,7 +160,7 @@ const toggleMobileMenu = () => {
Discord社区
</a>
</div>
<p>&copy; 2025 Crypto.AI - Web3超级AI Agent</p>
<p>&copy; 2025 Crypto.AI - AI Agent for Web3</p>
</div>
</footer>
</div>
@ -159,8 +261,9 @@ body {
padding: 0 var(--content-padding);
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
height: 100%;
position: relative;
}
.logo {
@ -168,56 +271,93 @@ body {
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
letter-spacing: 0.5px;
position: absolute;
left: calc(var(--content-padding) + (100% - var(--max-content-width)) / 2);
display: flex;
align-items: center;
gap: 0.8rem;
}
.logo-link {
display: flex;
align-items: center;
gap: 0.8rem;
color: var(--color-text-primary);
text-decoration: none;
transition: opacity 0.2s ease;
}
.logo-link:hover {
opacity: 0.9;
}
.logo-image {
height: 40px;
width: auto;
}
.main-nav {
/* 用户区域样式 */
.user-area {
display: flex;
gap: 2rem;
height: 100%;
align-items: center;
gap: 1rem;
}
.nav-link {
.auth-link {
color: var(--color-text-secondary);
text-decoration: none;
font-weight: var(--font-weight-medium);
transition: all 0.2s ease;
padding: 0 0.5rem;
height: 100%;
padding: 0.5rem 1rem;
}
.auth-link:hover {
color: var(--color-text-primary);
}
.register-link {
background-color: #3355ff;
color: white;
border-radius: var(--border-radius);
}
.register-link:hover {
background-color: #2244ee;
color: white;
}
.user-menu {
display: flex;
align-items: center;
position: relative;
gap: 1rem;
}
.nav-link:hover {
.user-info {
display: flex;
align-items: center;
}
.user-nickname {
font-weight: 500;
color: var(--color-text-primary);
}
.router-link-active {
color: var(--color-text-primary);
font-weight: var(--font-weight-bold);
.logout-button {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
padding: 0.3rem 0.8rem;
border-radius: var(--border-radius);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.router-link-active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: var(--color-accent);
.logout-button:hover {
background-color: rgba(255, 77, 77, 0.1);
color: #ff4d4d;
border-color: rgba(255, 77, 77, 0.3);
}
/* 移动菜单样式 */
/* 移动菜单按钮 */
.mobile-menu-toggle {
display: none;
background-color: rgba(255, 255, 255, 0.1);
@ -262,10 +402,123 @@ body {
width: 24px;
}
/* 移动菜单 */
.mobile-menu {
position: fixed;
top: var(--header-height);
right: -250px;
width: 250px;
height: calc(100vh - var(--header-height));
background-color: var(--color-bg-elevated);
z-index: 100;
transition: right 0.3s ease;
box-shadow: -2px 2px 10px rgba(0, 0, 0, 0.3);
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
padding: 1rem 0;
overflow-y: auto;
}
.mobile-menu-open {
right: 0;
}
.mobile-nav {
display: flex;
flex-direction: column;
width: 100%;
}
.mobile-nav-link {
padding: 1rem 1.5rem;
color: var(--color-text-secondary);
text-decoration: none;
font-weight: var(--font-weight-medium);
transition: all 0.2s ease;
width: 100%;
text-align: left;
border-bottom: 1px solid var(--color-divider);
}
.mobile-nav-link:hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--color-text-primary);
}
.mobile-nav-link.router-link-active {
color: var(--color-text-primary);
font-weight: var(--font-weight-bold);
border-left: 3px solid #3355ff;
}
.mobile-user-area {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
border-top: 1px solid var(--color-border);
}
.mobile-auth-link {
padding: 0.8rem 0;
width: 100%;
text-align: center;
color: var(--color-text-secondary);
text-decoration: none;
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius);
transition: all 0.2s ease;
}
.mobile-auth-link:hover {
color: var(--color-text-primary);
}
.mobile-register-link {
background-color: #3355ff;
color: white;
}
.mobile-register-link:hover {
background-color: #2244ee;
color: white;
}
.mobile-user-info {
padding: 0.5rem 0;
text-align: center;
}
.mobile-user-nickname {
font-weight: 500;
color: var(--color-text-primary);
}
.mobile-logout-button {
width: 100%;
padding: 0.8rem 0;
text-align: center;
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--border-radius);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.mobile-logout-button:hover {
background-color: rgba(255, 77, 77, 0.1);
color: #ff4d4d;
border-color: rgba(255, 77, 77, 0.3);
}
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
@ -349,6 +602,76 @@ body {
height: 18px;
}
/* 响应式设计 */
@media (max-width: 1280px) {
:root {
--content-padding: 2rem;
}
.content-container,
.header-content,
.footer-content {
width: 100%;
max-width: var(--max-content-width);
}
}
@media (max-width: 768px) {
:root {
--content-padding: 1rem;
--header-height: 60px;
}
.header-content {
justify-content: space-between;
}
.app-header {
height: var(--header-height);
position: sticky;
}
.logo {
font-size: 1.5rem;
}
.logo-image {
height: 32px;
}
.app-content {
padding: 1rem 0;
}
}
@media (max-width: 600px) {
:root {
--header-height: 56px;
}
.logo {
font-size: 1.3rem;
}
.logo-image {
height: 28px;
}
}
@media (max-width: 480px) {
.mobile-menu-toggle {
display: flex;
}
.user-area {
display: none;
}
.mobile-menu-overlay {
display: block;
}
}
/* 全局按钮样式 */
.btn {
padding: 0.8rem 1.6rem;
@ -396,139 +719,41 @@ body {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* 响应式设计 */
@media (max-width: 1280px) {
:root {
--content-padding: 2rem;
}
.content-container,
.header-content,
.footer-content {
width: 100%;
max-width: var(--max-content-width);
}
.logo {
left: var(--content-padding);
}
/* 用户等级标签样式 */
.user-level {
display: inline-block;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
margin-left: 0.5rem;
font-weight: var(--font-weight-medium);
}
@media (max-width: 768px) {
:root {
--content-padding: 1rem;
--header-height: 60px;
}
.header-content {
flex-direction: row;
justify-content: space-between;
padding: 0 var(--content-padding);
}
.app-header {
height: var(--header-height);
position: sticky;
}
.logo {
position: static;
font-size: 1.5rem;
}
.logo-image {
height: 32px;
}
.main-nav {
width: auto;
justify-content: flex-end;
gap: 1.5rem;
margin: 0;
}
.nav-link {
padding: 0 0.5rem;
}
.router-link-active::after {
bottom: -1px;
}
.app-content {
padding: 1rem 0;
}
.user-level-normal {
background-color: #555555;
color: #ffffff;
}
@media (max-width: 600px) {
:root {
--header-height: 56px;
}
.logo {
font-size: 1.3rem;
}
.logo-image {
height: 28px;
}
.main-nav {
gap: 1rem;
}
.nav-link {
font-size: 0.9rem;
}
.user-level-vip {
background-color: #ff9800;
color: #000000;
}
@media (max-width: 480px) {
.mobile-menu-toggle {
display: flex;
}
.user-level-super-vip {
background-color: #f44336;
color: #ffffff;
background-image: linear-gradient(45deg, #f44336, #ff9800);
}
.main-nav {
position: absolute;
top: var(--header-height);
right: -100%;
width: 200px;
height: auto;
min-height: 0;
background-color: var(--color-bg-elevated);
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15px 0;
transition: right 0.3s ease;
z-index: 100;
box-shadow: -2px 2px 10px rgba(0, 0, 0, 0.3);
border-radius: 0 0 0 8px;
border: 1px solid var(--color-border);
border-top: none;
}
.mobile-user-level {
margin-top: 0.25rem;
margin-left: 0;
}
.main-nav.mobile-menu-open {
right: 0;
}
.nav-link {
height: 44px;
padding: 0;
font-size: 1rem;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.router-link-active::after {
bottom: 10px;
width: 30px;
left: calc(50% - 15px);
}
.mobile-menu-overlay {
display: block;
}
.mobile-user-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
</style>

View File

@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { useUserStore } from '../stores/user'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -18,13 +19,54 @@ const router = createRouter({
path: '/ai-agent',
name: 'ai-agent',
component: () => import('../views/AIAgentView.vue'),
meta: { requiresVIP: true },
},
{
path: '/ai-feed',
name: 'ai-feed',
component: () => import('../views/AIFeedView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { requiresGuest: true },
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
meta: { requiresGuest: true },
},
],
})
// 路由守卫
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const isAuthenticated = userStore.isAuthenticated
const userInfo = userStore.userInfo
// 如果页面需要未登录状态(如登录、注册页面)
if (to.meta.requiresGuest && isAuthenticated) {
next('/')
return
}
// 如果页面需要VIP权限
if (to.meta.requiresVIP) {
if (!isAuthenticated) {
// 未登录用户,可以直接访问页面,在页面内会显示登录提示
next()
return
} else if (userInfo && userInfo.level < 1) {
// 已登录但不是VIP用户可以直接访问页面在页面内会显示升级提示
next()
return
}
}
next()
})
export default router

99
src/services/api.ts Normal file
View File

@ -0,0 +1,99 @@
import { useUserStore } from '../stores/user'
// 根据环境选择API基础URL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
// 用户认证相关API
export const authApi = {
// 发送验证码
async sendVerificationCode(mail: string) {
const response = await fetch(`${apiBaseUrl}/user/send-verification-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ mail }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `请求失败: ${response.status}`)
}
return await response.json()
},
// 用户注册
async register(data: {
mail: string
nickname: string
password: string
verification_code: string
}) {
const response = await fetch(`${apiBaseUrl}/user/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `请求失败: ${response.status}`)
}
return await response.json()
},
// 用户登录
async login(data: { mail: string; password: string }) {
const response = await fetch(`${apiBaseUrl}/user/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `请求失败: ${response.status}`)
}
const authData = await response.json()
// 使用Pinia存储登录信息
const userStore = useUserStore()
userStore.setAuth(authData)
return authData
},
}
// 创建带有认证头的请求函数
export function createAuthenticatedFetch() {
const userStore = useUserStore()
return async function authenticatedFetch(url: string, options: RequestInit = {}) {
const headers = {
...options.headers,
Authorization: userStore.authHeader,
}
const response = await fetch(url, {
...options,
headers,
})
// 处理401错误未授权
if (response.status === 401) {
userStore.logout()
window.location.href = '/login'
throw new Error('登录已过期,请重新登录')
}
return response
}
}

68
src/stores/user.ts Normal file
View File

@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface UserInfo {
id: number
mail: string
nickname: string
level: number
create_time: string
}
export interface AuthResponse {
access_token: string
token_type: string
expires_in: number
user_info: UserInfo
}
export const useUserStore = defineStore('user', () => {
// 状态
const accessToken = ref<string | null>(localStorage.getItem('access_token'))
const userInfo = ref<UserInfo | null>(null)
const isAuthenticated = computed(() => !!accessToken.value)
// 从本地存储中恢复用户信息
try {
const savedUserInfo = localStorage.getItem('user_info')
if (savedUserInfo) {
userInfo.value = JSON.parse(savedUserInfo)
}
} catch (error) {
console.error('Failed to parse user info from localStorage:', error)
}
// 设置用户信息和令牌
function setAuth(authResponse: AuthResponse) {
accessToken.value = authResponse.access_token
userInfo.value = authResponse.user_info
// 保存到本地存储
localStorage.setItem('access_token', authResponse.access_token)
localStorage.setItem('user_info', JSON.stringify(authResponse.user_info))
}
// 登出
function logout() {
accessToken.value = null
userInfo.value = null
// 清除本地存储
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
}
// 获取认证头
const authHeader = computed(() => {
return accessToken.value ? `Bearer ${accessToken.value}` : ''
})
return {
accessToken,
userInfo,
isAuthenticated,
authHeader,
setAuth,
logout,
}
})

View File

@ -1,5 +1,15 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, computed, onMounted } from 'vue'
import { useUserStore } from '../stores/user'
//
const userStore = useUserStore()
const isAuthenticated = computed(() => userStore.isAuthenticated)
const userInfo = computed(() => userStore.userInfo)
const isVIP = computed(() => userInfo.value && userInfo.value.level >= 1)
// 访
const showAccessDeniedAlert = ref(false)
// APIURL
const apiBaseUrl =
@ -9,12 +19,28 @@ const userInput = ref('')
const chatHistory = ref([
{
role: 'assistant',
content: '你好!我是区块链智能AI助手可以回答你的任何关于区块链的问题。请告诉我你想了解什么?',
content: '你好!我是AI Agent可以回答你的任何关于Web3的问题。请告诉我你想了解什么?',
},
])
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
// VIP
onMounted(() => {
if (isAuthenticated.value && !isVIP.value) {
showAccessDeniedAlert.value = true
// 5
setTimeout(() => {
showAccessDeniedAlert.value = false
}, 5000)
}
})
const closeAlert = () => {
showAccessDeniedAlert.value = false
}
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
@ -23,7 +49,7 @@ const scrollToBottom = async () => {
}
const sendMessage = async () => {
if (!userInput.value.trim() || isLoading.value) return
if (!userInput.value.trim() || isLoading.value || !isAuthenticated.value || !isVIP.value) return
//
chatHistory.value.push({
@ -47,12 +73,20 @@ const sendMessage = async () => {
await scrollToBottom()
try {
//
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
//
if (userStore.authHeader) {
headers['Authorization'] = userStore.authHeader
}
//
const response = await fetch(`${apiBaseUrl}/agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
user_prompt: currentInput,
}),
@ -99,12 +133,52 @@ const sendMessage = async () => {
<template>
<div class="ai-agent-view">
<h1 class="page-title">AI Agent</h1>
<p class="page-description">为您提供全面区块链信息查询和专业分析</p>
<!-- 访问限制提示 -->
<div v-if="showAccessDeniedAlert" class="access-denied-alert">
<div class="alert-content">
<span>AI Agent功能仅对VIP及超级VIP用户开放</span>
<button class="close-alert" @click="closeAlert">×</button>
</div>
</div>
<div class="chat-container">
<!-- <h1 class="page-title">AI Agent</h1>
<p class="page-description">为您提供全面区块链信息查询和专业分析</p> -->
<!-- 未登录状态显示登录提示 -->
<div v-if="!isAuthenticated" class="login-prompt">
<div class="login-prompt-content">
<h2>需要登录</h2>
<p>请登录或注册账号以使用AI Agent功能</p>
<div class="login-prompt-actions">
<router-link to="/login" class="login-button">登录</router-link>
<router-link to="/register" class="register-button">注册</router-link>
</div>
</div>
</div>
<!-- 已登录但不是VIP用户 -->
<div v-else-if="!isVIP" class="vip-prompt">
<div class="vip-prompt-content">
<h2>VIP专属功能</h2>
<p>AI Agent功能仅对VIP及超级VIP用户开放</p>
<div class="vip-level-info">
<div class="vip-level">
<div class="level-badge user-level-vip">VIP</div>
<div class="level-description">解锁AI Agent功能</div>
</div>
<div class="vip-level">
<div class="level-badge user-level-super-vip">超级VIP</div>
<div class="level-description">解锁AI Agent高级功能</div>
</div>
</div>
<p class="upgrade-hint">请联系客服升级您的账户</p>
</div>
</div>
<!-- 已登录且是VIP用户显示聊天界面 -->
<div v-else class="chat-container">
<div class="chat-header">
<h2>区块链智能AI助手</h2>
<h2>Web3 AI Agent</h2>
</div>
<div ref="messagesContainer" class="chat-messages">
@ -151,6 +225,52 @@ const sendMessage = async () => {
.ai-agent-view {
width: 100%;
padding: 0;
position: relative;
}
/* 访问限制提示样式 */
.access-denied-alert {
position: fixed;
top: calc(var(--header-height) + 20px);
left: 50%;
transform: translateX(-50%);
background-color: rgba(255, 152, 0, 0.95);
color: #000;
padding: 12px 20px;
border-radius: var(--border-radius);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
max-width: 90%;
width: auto;
}
.alert-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-weight: 500;
}
.close-alert {
background: none;
border: none;
color: #000;
font-size: 1.2rem;
cursor: pointer;
padding: 0 4px;
}
@keyframes slideDown {
from {
transform: translate(-50%, -20px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
.page-title {
@ -164,6 +284,134 @@ const sendMessage = async () => {
margin-bottom: 2rem;
}
/* 登录提示样式 */
.login-prompt {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem 1rem;
width: 100%;
height: calc(80vh - 150px);
}
.login-prompt-content {
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
padding: 2rem;
text-align: center;
max-width: 500px;
width: 100%;
border: 1px solid var(--color-border);
}
.login-prompt-content h2 {
font-size: 1.8rem;
margin-bottom: 1rem;
}
.login-prompt-content p {
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
.login-prompt-actions {
display: flex;
justify-content: center;
gap: 1rem;
}
.login-button,
.register-button {
padding: 0.8rem 2rem;
border-radius: var(--border-radius);
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.login-button {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.login-button:hover {
background-color: var(--color-bg-elevated);
border-color: var(--color-border-hover);
}
.register-button {
background-color: #3355ff;
color: white;
border: 1px solid #3355ff;
}
.register-button:hover {
background-color: #2244ee;
}
/* VIP提示样式 */
.vip-prompt {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem 1rem;
width: 100%;
height: calc(80vh - 150px);
}
.vip-prompt-content {
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
padding: 2rem;
text-align: center;
max-width: 500px;
width: 100%;
border: 1px solid var(--color-border);
}
.vip-prompt-content h2 {
font-size: 1.8rem;
margin-bottom: 1rem;
}
.vip-prompt-content p {
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
.vip-level-info {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
}
.vip-level {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.level-badge {
padding: 0.3rem 1rem;
border-radius: 4px;
font-weight: 500;
}
.level-description {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.upgrade-hint {
font-style: italic;
font-size: 0.9rem;
color: var(--color-text-tertiary);
}
/* 聊天界面样式 */
.chat-container {
width: 100%;
display: flex;
@ -290,5 +538,24 @@ const sendMessage = async () => {
.chat-container {
height: 70vh;
}
.login-prompt {
height: 70vh;
}
.vip-prompt {
height: 70vh;
}
}
@media (max-width: 480px) {
.login-prompt-actions {
flex-direction: column;
}
.vip-level-info {
flex-direction: column;
gap: 1rem;
}
}
</style>

View File

@ -1,6 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { marked } from 'marked'
import { useUserStore } from '../stores/user'
//
const userStore = useUserStore()
const isAuthenticated = computed(() => userStore.isAuthenticated)
// APIURL
const apiBaseUrl =
@ -42,13 +47,24 @@ const renderMarkdown = (content: string): string => {
//
const loadFeed = async () => {
if (isLoading.value || !hasMore.value) return
if (isLoading.value || !hasMore.value || !isAuthenticated.value) return
isLoading.value = true
error.value = null
try {
const response = await fetch(`${apiBaseUrl}/feed?limit=${limit.value}&skip=${skip.value}`)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
//
if (userStore.authHeader) {
headers['Authorization'] = userStore.authHeader
}
const response = await fetch(`${apiBaseUrl}/feed?limit=${limit.value}&skip=${skip.value}`, {
headers,
})
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
@ -91,7 +107,9 @@ const refreshFeed = () => {
//
onMounted(() => {
loadFeed()
if (isAuthenticated.value) {
loadFeed()
}
})
</script>
@ -100,70 +118,85 @@ onMounted(() => {
<!-- <h1 class="page-title">AI Agent 分析流</h1>
<p class="page-description">实时区块链市场分析和交易建议</p> -->
<div class="feed-actions">
<button @click="refreshFeed" class="refresh-button" :disabled="isLoading">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="refresh-icon"
>
<path
d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"
/>
</svg>
<span v-if="!isLoading">刷新</span>
<span v-else>加载中...</span>
</button>
<!-- 未登录状态显示登录提示 -->
<div v-if="!isAuthenticated" class="login-prompt">
<div class="login-prompt-content">
<h2>需要登录</h2>
<p>请登录或注册账号以查看AI分析内容</p>
<div class="login-prompt-actions">
<router-link to="/login" class="login-button">登录</router-link>
<router-link to="/register" class="register-button">注册</router-link>
</div>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
<button @click="refreshFeed" class="retry-button">重试</button>
</div>
<div class="feed-container">
<div v-if="feedItems.length === 0 && !isLoading" class="empty-state">暂无数据</div>
<div v-for="item in feedItems" :key="item.id" class="feed-item">
<div class="feed-header">
<div class="avatar">
<img
v-if="item.avatar_url"
:src="item.avatar_url"
:alt="item.agent_name"
class="avatar-img"
<!-- 已登录状态显示Feed内容 -->
<template v-else>
<div class="feed-actions">
<button @click="refreshFeed" class="refresh-button" :disabled="isLoading">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="refresh-icon"
>
<path
d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"
/>
<div v-else class="avatar-placeholder">
{{ item.agent_name.charAt(0) }}
</div>
</div>
<div class="feed-info">
<div class="feed-info-row">
<h3 class="agent-name">{{ item.agent_name }}</h3>
<p class="timestamp">{{ formatDate(item.create_time) }}</p>
</svg>
<span v-if="!isLoading">刷新</span>
<span v-else>加载中...</span>
</button>
</div>
<div v-if="error" class="error-message">
{{ error }}
<button @click="refreshFeed" class="retry-button">重试</button>
</div>
<div class="feed-container">
<div v-if="feedItems.length === 0 && !isLoading" class="empty-state">暂无数据</div>
<div v-for="item in feedItems" :key="item.id" class="feed-item">
<div class="feed-header">
<div class="avatar">
<img
v-if="item.avatar_url"
:src="item.avatar_url"
:alt="item.agent_name"
class="avatar-img"
/>
<div v-else class="avatar-placeholder">
{{ item.agent_name.charAt(0) }}
</div>
</div>
<div class="feed-info">
<div class="feed-info-row">
<h3 class="agent-name">{{ item.agent_name }}</h3>
<p class="timestamp">{{ formatDate(item.create_time) }}</p>
</div>
</div>
</div>
<div class="feed-content markdown-body" v-html="item.rendered_content"></div>
</div>
<div class="feed-content markdown-body" v-html="item.rendered_content"></div>
</div>
<div v-if="isLoading" class="loading-indicator">
<div class="spinner"></div>
<span>加载中...</span>
</div>
<div v-if="isLoading" class="loading-indicator">
<div class="spinner"></div>
<span>加载中...</span>
<div v-if="hasMore && !isLoading && feedItems.length > 0" class="load-more">
<button @click="loadMore" class="load-more-button">加载更多</button>
</div>
</div>
<div v-if="hasMore && !isLoading && feedItems.length > 0" class="load-more">
<button @click="loadMore" class="load-more-button">加载更多</button>
</div>
</div>
</template>
</div>
</template>
@ -582,5 +615,73 @@ onMounted(() => {
.timestamp {
font-size: 0.8rem;
}
.login-prompt-actions {
flex-direction: column;
}
}
/* 登录提示样式 */
.login-prompt {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem 1rem;
width: 100%;
}
.login-prompt-content {
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 2rem;
text-align: center;
max-width: 400px;
width: 100%;
}
.login-prompt-content h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.login-prompt-content p {
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.login-prompt-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.login-button,
.register-button {
padding: 0.7rem 1.5rem;
border-radius: var(--border-radius);
font-weight: 600;
text-decoration: none;
transition: all 0.2s ease;
}
.login-button {
background-color: #3355ff;
color: white;
}
.login-button:hover {
background-color: #2244ee;
}
.register-button {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.register-button:hover {
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
}
</style>

View File

@ -1,14 +1,91 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '../stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const isAuthenticated = computed(() => userStore.isAuthenticated)
const goToAIAgent = () => {
router.push('/ai-agent')
}
const goToAIFeed = () => {
router.push('/ai-feed')
}
</script>
<template>
<div class="home-view">
<section class="hero-section">
<div class="hero-content">
<h1 class="hero-title"><span class="accent">Web3 超级AI Agent</span></h1>
<h1 class="hero-title"><span class="accent">AI Agent for Web3</span></h1>
<p class="hero-subtitle">立刻加入开启 Web3 + AI 新时代</p>
<div class="hero-actions">
<a href="https://discord.gg/8vMDD4kC" target="_blank" class="btn btn-discord">
<svg class="discord-hero-icon" viewBox="0 0 24 24" width="24" height="24">
<!-- 已登录状态显示两个功能入口 -->
<template v-if="isAuthenticated">
<div class="feature-buttons">
<button @click="goToAIAgent" class="feature-button">
<div class="feature-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2a10 10 0 1 0 10 10H12V2z"></path>
<path d="M12 2a10 10 0 0 1 10 10h-10V2z"></path>
<path d="M12 22v-10h10"></path>
</svg>
</div>
<div class="feature-content">
<h3>AI Agent</h3>
<p>VIP专属智能 AI 助手</p>
</div>
</button>
<button @click="goToAIFeed" class="feature-button">
<div class="feature-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10"></line>
<line x1="18" y1="20" x2="18" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="16"></line>
</svg>
</div>
<div class="feature-content">
<h3>AI行情分析</h3>
<p>专业市场行情分析深度洞察趋势</p>
</div>
</button>
</div>
</template>
<!-- 未登录状态显示登录和注册按钮 -->
<template v-else>
<router-link to="/login" class="btn btn-primary"> 登录 </router-link>
<router-link to="/register" class="btn btn-secondary"> 注册 </router-link>
</template>
</div>
<div class="discord-link">
<a href="https://discord.gg/8vMDD4kC" target="_blank" class="discord-text">
<svg class="discord-icon" viewBox="0 0 24 24" width="16" height="16">
<path
fill="currentColor"
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
@ -74,7 +151,7 @@
}
.hero-title {
font-size: 3.5rem;
font-size: 3rem;
font-weight: 800;
margin-bottom: 1.5rem;
line-height: 1.2;
@ -100,7 +177,7 @@
}
.hero-subtitle {
font-size: 1.2rem;
font-size: 1rem;
color: var(--color-text-secondary);
max-width: 700px;
margin: 0 auto 2.5rem;
@ -113,28 +190,81 @@
margin-top: 1.5rem;
}
.btn-discord {
.feature-buttons {
display: flex;
gap: 1.5rem;
width: 100%;
max-width: 800px;
justify-content: center;
}
.feature-button {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
width: 280px;
text-align: center;
}
.feature-button:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border-color: #3355ff;
}
.feature-icon {
display: flex;
align-items: center;
gap: 0.6rem;
background-color: #5865f2;
justify-content: center;
width: 60px;
height: 60px;
background-color: rgba(51, 85, 255, 0.1);
border-radius: 50%;
margin-bottom: 1rem;
color: #3355ff;
}
.feature-content h3 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: white;
border: none;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.feature-content p {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0;
}
.discord-link {
margin-top: 2rem;
}
.discord-text {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s ease;
justify-content: center;
}
.btn-discord:hover {
background-color: #4752c4;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(88, 101, 242, 0.3);
.discord-text:hover {
color: var(--color-text-primary);
}
.discord-hero-icon {
width: 24px;
height: 24px;
.discord-icon {
width: 16px;
height: 16px;
}
.btn {
@ -145,17 +275,19 @@
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background-color: var(--color-accent);
color: var(--color-bg-primary);
background-color: #3355ff;
color: white;
font-weight: var(--font-weight-bold);
}
.btn-primary:hover {
background-color: var(--color-accent-hover);
color: var(--color-bg-primary);
background-color: #2244ee;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(51, 85, 255, 0.3);
}
.btn-secondary {
@ -166,145 +298,12 @@
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.features-section {
display: flex;
flex-wrap: wrap;
gap: 2rem;
margin-bottom: 4rem;
width: 100%;
}
.feature-card {
flex: 1;
min-width: 300px;
padding: 2.5rem;
display: flex;
flex-direction: column;
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
border: 1px solid var(--color-border);
height: 100%;
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1.5rem;
background-color: var(--color-accent-light);
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 15px;
}
.feature-title {
font-size: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.feature-desc {
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
line-height: 1.5;
flex-grow: 1;
}
.btn-action {
align-self: flex-start;
background: transparent;
color: var(--color-accent);
border: none;
padding: 0.5rem 0;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.2s ease;
}
.btn-action::after {
content: '→';
margin-left: 0.5rem;
transition: transform 0.2s ease;
}
.btn-action:hover::after {
transform: translateX(4px);
}
.stats-section {
margin-bottom: 4rem;
width: 100%;
}
.stats-header {
text-align: center;
margin-bottom: 2rem;
}
.stats-header h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.stats-header p {
color: var(--color-text-secondary);
}
.stats-grid {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
width: 100%;
}
.stat-card {
flex: 1;
min-width: 240px;
text-align: center;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
}
.stat-card:hover {
border-color: var(--color-accent);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-accent);
margin-bottom: 0.5rem;
}
.stat-label {
color: var(--color-text-secondary);
font-size: 1.1rem;
}
@media (max-width: 980px) {
.feature-card {
min-width: calc(50% - 1rem);
}
transform: translateY(-2px);
}
@media (max-width: 768px) {
.hero-title {
font-size: 2.5rem;
font-size: 2.2rem;
}
.hero-actions {
@ -312,26 +311,24 @@
align-items: center;
}
.feature-card {
min-width: 100%;
.feature-buttons {
flex-direction: column;
gap: 1rem;
}
.stat-card {
min-width: calc(50% - 1rem);
.feature-button {
width: 100%;
max-width: 320px;
}
}
@media (max-width: 480px) {
.stat-card {
min-width: 100%;
}
.hero-section {
padding: 3rem 1rem;
}
.hero-title {
font-size: 2.2rem;
font-size: 2rem;
}
}
</style>

182
src/views/LoginView.vue Normal file
View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authApi } from '@/services/api'
const router = useRouter()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
if (!email.value || !password.value) {
error.value = '请填写邮箱和密码'
return
}
try {
loading.value = true
error.value = ''
await authApi.login({
mail: email.value,
password: password.value,
})
//
router.push('/')
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败,请重试'
console.error('登录失败:', err)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-view">
<div class="auth-container">
<h1 class="auth-title">登录</h1>
<form @submit.prevent="handleLogin" class="auth-form">
<div class="form-group">
<label for="email">邮箱</label>
<input
type="email"
id="email"
v-model="email"
placeholder="请输入邮箱"
required
autocomplete="email"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
v-model="password"
placeholder="请输入密码"
required
autocomplete="current-password"
/>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" class="auth-button" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div class="auth-links">
<router-link to="/register">没有账号立即注册</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.login-view {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--header-height) - 100px);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 400px;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 2rem;
}
.auth-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
text-align: center;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.form-group input {
padding: 0.8rem 1rem;
border-radius: var(--border-radius);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
font-size: 1rem;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #3355ff;
box-shadow: 0 0 0 2px rgba(51, 85, 255, 0.2);
}
.error-message {
color: #ff4d4d;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.auth-button {
margin-top: 1rem;
padding: 0.8rem;
border-radius: var(--border-radius);
background-color: #3355ff;
color: white;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-button:hover:not(:disabled) {
background-color: #2244ee;
}
.auth-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.auth-links {
margin-top: 1.5rem;
text-align: center;
}
.auth-links a {
color: #3355ff;
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
</style>

315
src/views/RegisterView.vue Normal file
View File

@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authApi } from '@/services/api'
const router = useRouter()
const email = ref('')
const nickname = ref('')
const password = ref('')
const confirmPassword = ref('')
const verificationCode = ref('')
const loading = ref(false)
const error = ref('')
const sendingCode = ref(false)
const codeSent = ref(false)
const countdown = ref(0)
//
const sendVerificationCode = async () => {
if (!email.value) {
error.value = '请填写邮箱'
return
}
try {
sendingCode.value = true
error.value = ''
await authApi.sendVerificationCode(email.value)
codeSent.value = true
countdown.value = 60
//
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} catch (err) {
error.value = err instanceof Error ? err.message : '发送验证码失败,请重试'
console.error('发送验证码失败:', err)
} finally {
sendingCode.value = false
}
}
//
const handleRegister = async () => {
if (!email.value || !nickname.value || !password.value || !verificationCode.value) {
error.value = '请填写所有必填项'
return
}
if (password.value !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
try {
loading.value = true
error.value = ''
await authApi.register({
mail: email.value,
nickname: nickname.value,
password: password.value,
verification_code: verificationCode.value,
})
//
router.push('/login')
} catch (err) {
error.value = err instanceof Error ? err.message : '注册失败,请重试'
console.error('注册失败:', err)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="register-view">
<div class="auth-container">
<h1 class="auth-title">注册</h1>
<form @submit.prevent="handleRegister" class="auth-form">
<div class="form-group">
<label for="email">邮箱</label>
<div class="input-with-button">
<input
type="email"
id="email"
v-model="email"
placeholder="请输入邮箱"
required
autocomplete="email"
/>
<button
type="button"
class="code-button"
@click="sendVerificationCode"
:disabled="sendingCode || countdown > 0"
>
{{
countdown > 0 ? `${countdown}秒后重试` : sendingCode ? '发送中...' : '获取验证码'
}}
</button>
</div>
</div>
<div class="form-group">
<label for="verification-code">验证码</label>
<input
type="text"
id="verification-code"
v-model="verificationCode"
placeholder="请输入验证码"
required
/>
</div>
<div class="form-group">
<label for="nickname">昵称</label>
<input
type="text"
id="nickname"
v-model="nickname"
placeholder="请输入昵称"
required
autocomplete="nickname"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
v-model="password"
placeholder="请输入密码"
required
autocomplete="new-password"
/>
</div>
<div class="form-group">
<label for="confirm-password">确认密码</label>
<input
type="password"
id="confirm-password"
v-model="confirmPassword"
placeholder="请再次输入密码"
required
autocomplete="new-password"
/>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" class="auth-button" :disabled="loading">
{{ loading ? '注册中...' : '注册' }}
</button>
</form>
<div class="auth-links">
<router-link to="/login">已有账号立即登录</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.register-view {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--header-height) - 100px);
padding: 2rem 1rem;
}
.auth-container {
width: 100%;
max-width: 400px;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 2rem;
}
.auth-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
text-align: center;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.form-group input {
padding: 0.8rem 1rem;
border-radius: var(--border-radius);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
font-size: 1rem;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #3355ff;
box-shadow: 0 0 0 2px rgba(51, 85, 255, 0.2);
}
.input-with-button {
display: flex;
gap: 0.5rem;
}
.input-with-button input {
flex: 1;
}
.code-button {
padding: 0 1rem;
border-radius: var(--border-radius);
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
}
.code-button:hover:not(:disabled) {
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.code-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
color: #ff4d4d;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.auth-button {
margin-top: 1rem;
padding: 0.8rem;
border-radius: var(--border-radius);
background-color: #3355ff;
color: white;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-button:hover:not(:disabled) {
background-color: #2244ee;
}
.auth-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.auth-links {
margin-top: 1.5rem;
text-align: center;
}
.auth-links a {
color: #3355ff;
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.input-with-button {
flex-direction: column;
}
.code-button {
padding: 0.8rem;
}
}
</style>