feat(global-header): 重构全局头部组件及样式优化
- 完全重写 GlobalHeader 组件结构,改用 div 语义及自定义样式替代原antd a-menu - 新增 Logo 区域包含图标和标题,支持点击跳转首页功能 - 实现导航菜单动态渲染,根据路由权限过滤显示 - 用户区域支持未登录和已登录两种状态切换 - 未登录时展示登录、注册按钮,支持路由跳转 - 已登录时显示用户头像、用户名及下拉菜单,包含个人中心、设置和退出登录操作 - 引入 Arco Design 图标组件优化视觉表现 - 完善登出流程,清理本地Token并提示用户 - 优化响应式布局和交互体验,提升用户界面整体一致性与可用性
This commit is contained in:
201
src/api/aiChat.ts
Normal file
201
src/api/aiChat.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
stream: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
delta: {
|
||||
content?: string;
|
||||
role?: string;
|
||||
};
|
||||
finish_reason?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
class ChatAPI {
|
||||
private baseURL: string;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
|
||||
constructor() {
|
||||
this.baseURL =
|
||||
import.meta.env.VITE_CHAT_API_BASE_URL || "https://api.openai.com";
|
||||
this.apiKey = import.meta.env.VITE_CHAT_API_KEY || "";
|
||||
this.model = import.meta.env.VITE_CHAT_MODEL || "gpt-3.5-turbo";
|
||||
}
|
||||
|
||||
private validateConfig(): void {
|
||||
if (!this.apiKey) {
|
||||
throw new Error(
|
||||
"Chat API Key 未配置,请在环境变量中设置 VITE_CHAT_API_KEY"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
messages: ChatMessage[],
|
||||
onChunk?: (chunk: string) => void
|
||||
): Promise<string> {
|
||||
this.validateConfig();
|
||||
|
||||
const request: ChatRequest = {
|
||||
model: this.model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
max_tokens: 4000,
|
||||
top_p: 0.95,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
};
|
||||
|
||||
console.log("发送请求到:", `${this.baseURL}/chat/completions`);
|
||||
console.log("请求内容:", JSON.stringify(request, null, 2));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
|
||||
console.log("响应状态:", response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error("API错误响应:", errorData);
|
||||
throw { response: { status: response.status, data: errorData } };
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("响应体为空");
|
||||
}
|
||||
|
||||
console.log("开始处理流式响应");
|
||||
return await this.handleStreamResponse(response.body, onChunk);
|
||||
} catch (error: any) {
|
||||
console.error("请求失败:", error);
|
||||
throw this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStreamResponse(
|
||||
responseBody: ReadableStream,
|
||||
onChunk?: (chunk: string) => void
|
||||
): Promise<string> {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = "";
|
||||
let chunkCount = 0;
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
console.log("流式响应完成,总共收到", chunkCount, "个chunk");
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
console.log("收到原始chunk:", chunk);
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
if (trimmedLine.startsWith("data:")) {
|
||||
const data = trimmedLine.slice(5).trim();
|
||||
console.log("解析数据行:", data);
|
||||
|
||||
if (data === "[DONE]") {
|
||||
console.log("收到[DONE]信号,流式响应结束");
|
||||
return fullContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: StreamChunk = JSON.parse(data);
|
||||
const content = parsed.choices[0]?.delta?.content;
|
||||
|
||||
if (content) {
|
||||
chunkCount++;
|
||||
fullContent += content;
|
||||
console.log("提取到内容:", content);
|
||||
if (onChunk) {
|
||||
onChunk(content);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn("解析流式数据失败:", parseError, "数据:", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
console.log("最终完整内容:", fullContent);
|
||||
return fullContent;
|
||||
}
|
||||
|
||||
private handleError(error: any): Error {
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
return new Error("API密钥无效,请检查配置");
|
||||
case 429:
|
||||
return new Error("API调用频率限制,请稍后重试");
|
||||
case 500:
|
||||
return new Error("服务器错误,请稍后重试");
|
||||
default:
|
||||
return new Error(
|
||||
`API调用失败: ${data?.error?.message || "未知错误"}`
|
||||
);
|
||||
}
|
||||
} else if (error.name === "AbortError") {
|
||||
return new Error("请求超时,请检查网络连接");
|
||||
} else {
|
||||
return new Error(`网络错误: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.sendMessage([{ role: "user", content: "Hello" }]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Chat API连接测试失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const chatAPI = new ChatAPI();
|
||||
81
src/api/auth.ts
Normal file
81
src/api/auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import request from "@/plugins/axios";
|
||||
|
||||
/**
|
||||
* 认证服务 API 类型定义
|
||||
*/
|
||||
|
||||
// 统一响应格式
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 登录请求参数
|
||||
export interface LoginRequest {
|
||||
userAccount: string;
|
||||
userPassword: string;
|
||||
}
|
||||
|
||||
// 登录响应数据
|
||||
export interface LoginResponse {
|
||||
id: number;
|
||||
userAccount: string;
|
||||
unionId: string | null;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expire: number | null;
|
||||
}
|
||||
|
||||
// 刷新令牌响应数据
|
||||
export interface RefreshTokenResponse {
|
||||
id: number | null;
|
||||
userAccount: string | null;
|
||||
unionId: string | null;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expire: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证服务 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param data 登录请求参数
|
||||
* @returns 登录响应数据
|
||||
*/
|
||||
export const login = (data: LoginRequest): Promise<ApiResponse<LoginResponse>> => {
|
||||
return request.post("/v1/auth/login", data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 令牌刷新
|
||||
* @param refreshToken 刷新令牌
|
||||
* @returns 新的令牌信息
|
||||
*/
|
||||
export const refreshToken = (refreshToken: string): Promise<ApiResponse<RefreshTokenResponse>> => {
|
||||
return request.post("/v1/auth/refresh", null, {
|
||||
params: {
|
||||
refreshToken,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取访问令牌(简化版登录)
|
||||
* @param data 登录请求参数
|
||||
* @returns 访问令牌字符串
|
||||
*/
|
||||
export const getAccessToken = (data: LoginRequest): Promise<ApiResponse<string>> => {
|
||||
return request.post("/v1/auth/auth", data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 令牌验证
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export const validateToken = (): Promise<ApiResponse<boolean>> => {
|
||||
return request.post("/v1/auth/validate");
|
||||
};
|
||||
13
src/api/index.ts
Normal file
13
src/api/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { chatAPI, type ChatMessage, type ChatRequest } from './aiChat'
|
||||
|
||||
// 认证 API
|
||||
export {
|
||||
login,
|
||||
refreshToken,
|
||||
getAccessToken,
|
||||
validateToken,
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type RefreshTokenResponse,
|
||||
type ApiResponse,
|
||||
} from './auth'
|
||||
@@ -1,94 +1,378 @@
|
||||
<template>
|
||||
<a-row id="globalHeader" align="center" :wrap="false">
|
||||
<a-col flex="auto">
|
||||
<a-menu
|
||||
mode="horizontal"
|
||||
:selected-keys="selectedKeys"
|
||||
@menu-item-click="doMenuItemClick"
|
||||
>
|
||||
<a-menu-item
|
||||
key="0"
|
||||
:style="{ padding: 0, marginRight: '38px' }"
|
||||
disabled
|
||||
<div id="globalHeader">
|
||||
<div class="header-container">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="logo-section" @click="goToHome">
|
||||
<img class="logo" src="@/assets/logo.webp" alt="AI OJ Logo" />
|
||||
<div class="logo-title">
|
||||
<span class="title-main">AI OJ</span>
|
||||
<span class="title-sub">智能 OJ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-menu">
|
||||
<a
|
||||
v-for="item in visibleRoutes"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: selectedKeys.includes(item.path) }]"
|
||||
@click="doMenuItemClick(item.path)"
|
||||
>
|
||||
<div class="title-bar">
|
||||
<img class="logo" src="@/assets/logo.webp" />
|
||||
<div class="logo-title">AI OJ</div>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-for="item in visibleRoutes" :key="item.path">
|
||||
{{ item.name }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-col>
|
||||
<a-col flex="100px">
|
||||
<div>MeowRain</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 用户区域 -->
|
||||
<div class="user-section">
|
||||
<!-- 未登录状态 -->
|
||||
<div
|
||||
v-if="userStore.loginUser.userRole === ACCESS_ENUM.NOT_LOGIN"
|
||||
class="auth-buttons"
|
||||
>
|
||||
<a-button type="text" @click="goToLogin" class="login-btn">
|
||||
登录
|
||||
</a-button>
|
||||
<a-button type="primary" @click="goToRegister" class="register-btn">
|
||||
注册
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<a-dropdown v-else trigger="hover" position="br">
|
||||
<div class="user-info">
|
||||
<a-avatar :size="36" class="user-avatar">
|
||||
<IconUser />
|
||||
</a-avatar>
|
||||
<span class="username">{{ userStore.loginUser.userName }}</span>
|
||||
<icon-down class="dropdown-icon" />
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption @click="goToProfile">
|
||||
<icon-user />
|
||||
<span class="dropdown-text">个人中心</span>
|
||||
</a-doption>
|
||||
<a-doption @click="goToSettings">
|
||||
<icon-settings />
|
||||
<span class="dropdown-text">设置</span>
|
||||
</a-doption>
|
||||
<a-divider :margin="4" />
|
||||
<a-doption @click="handleLogout">
|
||||
<icon-export />
|
||||
<span class="dropdown-text">退出登录</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { routes } from "../router/index.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, ref } from "vue";
|
||||
import { Message } from "@arco-design/web-vue";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
IconExport,
|
||||
IconDown,
|
||||
} from "@arco-design/web-vue/es/icon";
|
||||
import checkAccess from "../access/checkAccess.ts";
|
||||
import { useUserStore } from "../store/user.ts";
|
||||
import { clearTokens } from "@/utils/token";
|
||||
import ACCESS_ENUM from "@/access/accessEnum";
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 默认主页
|
||||
const selectedKeys = ref(["/"]);
|
||||
const doMenuItemClick = (key: string) => {
|
||||
// console.info("触发菜单跳转,当前路径: ", key)
|
||||
router.push({
|
||||
path: key,
|
||||
});
|
||||
|
||||
const doMenuItemClick = (path: string) => {
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const goToHome = () => {
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
// 展示可见的路由
|
||||
const visibleRoutes = computed(() => {
|
||||
return routes.filter((item, index) => {
|
||||
return routes.filter((item) => {
|
||||
if (item?.meta?.hideInMenu) {
|
||||
return false;
|
||||
}
|
||||
// 根据权限过滤菜单
|
||||
if (!checkAccess(userStore.loginUser, item?.meta?.access)) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return checkAccess(userStore.loginUser, item?.meta?.access);
|
||||
});
|
||||
});
|
||||
/**
|
||||
* router.afterEach 是 Vue Router 的全局后置钩子(global after hook),会在每次路由导航完成后触发。它的典型用途是:
|
||||
*
|
||||
* 记录页面访问日志
|
||||
*
|
||||
* 改变页面标题
|
||||
*
|
||||
* 停止 loading 动画
|
||||
*
|
||||
* 做一些不影响导航结果的副作用(因为 afterEach 无法取消导航)
|
||||
*/
|
||||
router.afterEach((to, from, failure) => {
|
||||
console.log("导航已完成:", from.fullPath, "->", to.fullPath);
|
||||
|
||||
// 跳转到登录页
|
||||
const goToLogin = () => {
|
||||
router.push("/user/login");
|
||||
};
|
||||
|
||||
// 跳转到注册页
|
||||
const goToRegister = () => {
|
||||
router.push("/user/register");
|
||||
};
|
||||
|
||||
// 跳转到个人中心
|
||||
const goToProfile = () => {
|
||||
Message.info("个人中心功能开发中");
|
||||
};
|
||||
|
||||
// 跳转到设置页
|
||||
const goToSettings = () => {
|
||||
Message.info("设置功能开发中");
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
clearTokens();
|
||||
userStore.updateUserLoginStatus({
|
||||
userName: "未登录",
|
||||
userRole: ACCESS_ENUM.NOT_LOGIN,
|
||||
});
|
||||
Message.success("已退出登录");
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
router.afterEach((to) => {
|
||||
selectedKeys.value = [to.path];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#globalHeader {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
.header-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 40px;
|
||||
// Logo 区域
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
|
||||
.title-main {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.title-sub {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
margin-left: 10px;
|
||||
// 导航菜单
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
color: #4e5969;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
background: rgba(102, 126, 234, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #667eea;
|
||||
background: rgba(102, 126, 234, 0.12);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户区域
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.login-btn {
|
||||
color: #4e5969;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
padding: 0 20px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #5568d3 0%, #653a8b 100%);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(102, 126, 234, 0.06);
|
||||
border-color: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&:hover .dropdown-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉菜单样式
|
||||
:deep(.arco-dropdown-option) {
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dropdown-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(102, 126, 234, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="basicLayout">
|
||||
<a-layout style="height: 400px;">
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-header class="header">
|
||||
<GlobalHeader />
|
||||
</a-layout-header>
|
||||
@@ -8,39 +8,234 @@
|
||||
<RouterView />
|
||||
</a-layout-content>
|
||||
<a-layout-footer class="footer">
|
||||
<div class="footer-container">
|
||||
<!-- Footer 顶部 -->
|
||||
<div class="footer-top">
|
||||
<div class="footer-section">
|
||||
<div class="footer-logo">
|
||||
<img src="@/assets/logo.webp" alt="AI OJ" class="logo-img" />
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">AI OJ</span>
|
||||
<span class="logo-subtitle">智能在线评测平台</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-description">
|
||||
结合 AI 技术的在线编程学习与竞赛平台
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="https://meowrain.cn">AI OJ bY MeowRain</a>
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-title">快速链接</h3>
|
||||
<ul class="footer-links">
|
||||
<li><a href="#/home">首页</a></li>
|
||||
<li><a href="#/about">关于</a></li>
|
||||
<li><a href="#/ai/chat">AI 助手</a></li>
|
||||
<li><a href="#/user/login">登录</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-title">社区</h3>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://github.com" target="_blank">GitHub</a></li>
|
||||
<li><a href="#" target="_blank">Discord</a></li>
|
||||
<li><a href="#" target="_blank">问题反馈</a></li>
|
||||
<li><a href="#" target="_blank">更新日志</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h3 class="footer-title">联系我们</h3>
|
||||
<ul class="footer-links">
|
||||
<li>
|
||||
<a href="https://meowrain.cn" target="_blank">
|
||||
开发者主页
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="mailto:contact@aioj.com">联系邮箱</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer 底部 -->
|
||||
<div class="footer-bottom">
|
||||
<div class="copyright">
|
||||
© {{ currentYear }} AI OJ by
|
||||
<a
|
||||
href="https://meowrain.cn"
|
||||
target="_blank"
|
||||
class="author-link"
|
||||
>
|
||||
MeowRain
|
||||
</a>
|
||||
. All rights reserved.
|
||||
</div>
|
||||
<div class="footer-meta">
|
||||
<span>Made with ❤️ using Vue 3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalHeader from "../components/GlobalHeader.vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#basicLayout {}
|
||||
#basicLayout {
|
||||
.header {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: normal;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#basicLayout .header {
|
||||
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#basicLayout .content {
|
||||
background: linear-gradient(to right, #eee, #fff);
|
||||
margin-bottom: 16px;
|
||||
.content {
|
||||
background: #f7f8fa;
|
||||
min-height: calc(100vh - 64px - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #efefef;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1d2129;
|
||||
color: #c9cdd4;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px 24px;
|
||||
}
|
||||
|
||||
// Footer 顶部
|
||||
.footer-top {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 60px;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media (max-width: 992px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
.footer-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.logo-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
|
||||
.logo-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-description {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #86909c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 12px;
|
||||
|
||||
a {
|
||||
color: #86909c;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer 底部
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
.author-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #764ba2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
span {
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,4 +11,4 @@ const pinia = createPinia();
|
||||
app.use(router);
|
||||
app.use(ArcoVue);
|
||||
app.use(pinia)
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
@@ -3,6 +3,7 @@ import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { ENV } from "../config";
|
||||
import { getAccessToken } from "@/utils/token";
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: ENV.API_BASE_URL,
|
||||
@@ -14,8 +15,8 @@ const request: AxiosInstance = axios.create({
|
||||
// =======================
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig<any>) => {
|
||||
// 自动携带 token
|
||||
const token = localStorage.getItem("token");
|
||||
// 自动携带 access token
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
@@ -3,6 +3,8 @@ import HomeView from "../views/HomeView.vue";
|
||||
import AboutView from "../views/AboutView.vue";
|
||||
import ACCESS_ENUM from "../access/accessEnum";
|
||||
import UserLoginView from "../views/user/UserLoginView.vue";
|
||||
import UserRegisterView from "../views/user/UserRegisterView.vue";
|
||||
import AiChatView from "../views/ai/AiChatView.vue";
|
||||
/**
|
||||
* 路由配置
|
||||
*/
|
||||
@@ -19,10 +21,22 @@ export const routes: Array<RouteRecordRaw> = [
|
||||
component: AboutView,
|
||||
meta: { hideInMenu: false, access: ACCESS_ENUM.NOT_LOGIN },
|
||||
},
|
||||
{
|
||||
path: "/ai/chat",
|
||||
name: "AiChatView",
|
||||
component: AiChatView,
|
||||
meta: { hideInMenu: false, access: ACCESS_ENUM.NOT_LOGIN },
|
||||
},
|
||||
{
|
||||
path: "/user/login",
|
||||
name: "UserLoginView",
|
||||
component: UserLoginView,
|
||||
meta: { hideInMenu: false, access: ACCESS_ENUM.NOT_LOGIN },
|
||||
meta: { hideInMenu: true, access: ACCESS_ENUM.NOT_LOGIN },
|
||||
},
|
||||
{
|
||||
path: "/user/register",
|
||||
name: "UserRegisterView",
|
||||
component: UserRegisterView,
|
||||
meta: { hideInMenu: true, access: ACCESS_ENUM.NOT_LOGIN },
|
||||
},
|
||||
];
|
||||
|
||||
71
src/utils/token.ts
Normal file
71
src/utils/token.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Token 存储管理工具
|
||||
*/
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/**
|
||||
* 存储访问令牌
|
||||
*/
|
||||
export const setAccessToken = (token: string): void => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
*/
|
||||
export const getAccessToken = (): string | null => {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除访问令牌
|
||||
*/
|
||||
export const removeAccessToken = (): void => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 存储刷新令牌
|
||||
*/
|
||||
export const setRefreshToken = (token: string): void => {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
*/
|
||||
export const getRefreshToken = (): string | null => {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除刷新令牌
|
||||
*/
|
||||
export const removeRefreshToken = (): void => {
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* 存储令牌对(访问令牌和刷新令牌)
|
||||
*/
|
||||
export const setTokens = (accessToken: string, refreshToken: string): void => {
|
||||
setAccessToken(accessToken);
|
||||
setRefreshToken(refreshToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有令牌
|
||||
*/
|
||||
export const clearTokens = (): void => {
|
||||
removeAccessToken();
|
||||
removeRefreshToken();
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否已登录(是否有访问令牌)
|
||||
*/
|
||||
export const isLoggedIn = (): boolean => {
|
||||
return !!getAccessToken();
|
||||
};
|
||||
@@ -1,11 +1,500 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
HelloWorld
|
||||
</div>
|
||||
<div id="homeView">
|
||||
<!-- Hero 区域 -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<span class="gradient-text">AI Online Judge</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
智能编程评测平台 · 提升你的编程能力
|
||||
</p>
|
||||
<p class="hero-description">
|
||||
结合 AI 技术的在线编程学习与竞赛平台,提供海量题目、智能评测、实时排行
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a-button type="primary" size="large" @click="goToProblems">
|
||||
<template #icon>
|
||||
<icon-code />
|
||||
</template>
|
||||
开始刷题
|
||||
</a-button>
|
||||
<a-button size="large" @click="goToAiChat">
|
||||
<template #icon>
|
||||
<icon-robot />
|
||||
</template>
|
||||
AI 助手
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-illustration">
|
||||
<div class="floating-card card-1">
|
||||
<icon-code-block />
|
||||
<span>算法题库</span>
|
||||
</div>
|
||||
<div class="floating-card card-2">
|
||||
<icon-trophy />
|
||||
<span>竞赛排行</span>
|
||||
</div>
|
||||
<div class="floating-card card-3">
|
||||
<icon-fire />
|
||||
<span>实时评测</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-container">
|
||||
<div class="stat-item">
|
||||
<icon-user class="stat-icon" />
|
||||
<div class="stat-value">10,000+</div>
|
||||
<div class="stat-label">注册用户</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<icon-file class="stat-icon" />
|
||||
<div class="stat-value">5,000+</div>
|
||||
<div class="stat-label">题目数量</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<icon-check-circle class="stat-icon" />
|
||||
<div class="stat-value">100,000+</div>
|
||||
<div class="stat-label">提交次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<icon-trophy class="stat-icon" />
|
||||
<div class="stat-value">500+</div>
|
||||
<div class="stat-label">竞赛场次</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能特色 -->
|
||||
<section class="features-section">
|
||||
<h2 class="section-title">平台特色</h2>
|
||||
<p class="section-subtitle">为什么选择 AI OJ</p>
|
||||
<div class="features-grid">
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-code-block />
|
||||
</div>
|
||||
<h3 class="feature-title">海量题库</h3>
|
||||
<p class="feature-description">
|
||||
涵盖算法、数据结构、数学等多个领域,从入门到进阶,满足不同水平需求
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-robot />
|
||||
</div>
|
||||
<h3 class="feature-title">AI 辅助</h3>
|
||||
<p class="feature-description">
|
||||
智能题解分析、代码优化建议、学习路径推荐,让 AI 成为你的编程导师
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-fire />
|
||||
</div>
|
||||
<h3 class="feature-title">实时评测</h3>
|
||||
<p class="feature-description">
|
||||
毫秒级评测反馈,支持多种编程语言,详细的测试用例和错误信息
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-trophy />
|
||||
</div>
|
||||
<h3 class="feature-title">竞赛系统</h3>
|
||||
<p class="feature-description">
|
||||
定期举办编程竞赛,实时排行榜,与全国高手一较高下
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-user-group />
|
||||
</div>
|
||||
<h3 class="feature-title">社区交流</h3>
|
||||
<p class="feature-description">
|
||||
活跃的技术社区,题解分享、经验交流,结识志同道合的编程伙伴
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="feature-card" :bordered="false" hoverable>
|
||||
<div class="feature-icon">
|
||||
<icon-bar-chart />
|
||||
</div>
|
||||
<h3 class="feature-title">数据分析</h3>
|
||||
<p class="feature-description">
|
||||
详细的学习数据统计,可视化进度追踪,帮助你更好地规划学习
|
||||
</p>
|
||||
</a-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA 区域 -->
|
||||
<section class="cta-section">
|
||||
<div class="cta-content">
|
||||
<h2 class="cta-title">准备好开始了吗?</h2>
|
||||
<p class="cta-description">
|
||||
立即加入 AI OJ,开启你的编程进阶之旅
|
||||
</p>
|
||||
<div class="cta-actions">
|
||||
<a-button
|
||||
v-if="userStore.loginUser.userRole === ACCESS_ENUM.NOT_LOGIN"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToRegister"
|
||||
>
|
||||
免费注册
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="goToProblems"
|
||||
>
|
||||
开始刷题
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { useUserStore } from "@/store/user";
|
||||
import ACCESS_ENUM from "@/access/accessEnum";
|
||||
import {
|
||||
IconCode,
|
||||
IconRobot,
|
||||
IconCodeBlock,
|
||||
IconTrophy,
|
||||
IconFire,
|
||||
IconUser,
|
||||
IconFile,
|
||||
IconCheckCircle,
|
||||
IconUserGroup,
|
||||
IconBarChart,
|
||||
} from "@arco-design/web-vue/es/icon";
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const goToProblems = () => {
|
||||
// TODO: 题目列表页面路由
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
const goToAiChat = () => {
|
||||
router.push("/ai/chat");
|
||||
};
|
||||
|
||||
const goToRegister = () => {
|
||||
router.push("/user/register");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#homeView {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
// Hero 区域
|
||||
.hero-section {
|
||||
position: relative;
|
||||
min-height: 600px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 80px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(45deg, #fff, #f0f0ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 16px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:deep(.arco-btn) {
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-illustration {
|
||||
position: absolute;
|
||||
right: 10%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
animation: float 3s ease-in-out infinite;
|
||||
|
||||
svg {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&.card-1 {
|
||||
animation-delay: 0s;
|
||||
top: -50px;
|
||||
right: 50px;
|
||||
}
|
||||
|
||||
&.card-2 {
|
||||
animation-delay: 1s;
|
||||
top: 100px;
|
||||
right: -30px;
|
||||
}
|
||||
|
||||
&.card-3 {
|
||||
animation-delay: 2s;
|
||||
top: 250px;
|
||||
right: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
.stats-section {
|
||||
padding: 60px 40px;
|
||||
background: white;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
color: #667eea;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #1d2129;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
// 功能特色
|
||||
.features-section {
|
||||
padding: 80px 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #1d2129;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
color: #86909c;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
display: inline-block;
|
||||
|
||||
svg {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// CTA 区域
|
||||
.cta-section {
|
||||
padding: 80px 40px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cta-description {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.cta-actions {
|
||||
:deep(.arco-btn) {
|
||||
height: 48px;
|
||||
padding: 0 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
448
src/views/ai/AiChatView.vue
Normal file
448
src/views/ai/AiChatView.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="ai-chat-container">
|
||||
<a-card class="chat-card" :bordered="false">
|
||||
<template #title>
|
||||
<div class="chat-title">
|
||||
<icon-robot class="title-icon" />
|
||||
<span>AI 助手</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="chat-content">
|
||||
<div class="messages-container" ref="messagesContainer">
|
||||
<div v-for="(message, index) in messages" :key="index" class="message-wrapper" :class="message.role">
|
||||
<div class="message-content">
|
||||
<div class="message-avatar">
|
||||
<icon-user v-if="message.role === 'user'" />
|
||||
<icon-robot v-else />
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<div class="message-text" v-html="formatMessageContent(message.content)"></div>
|
||||
<div v-if="message.isStreaming && !message.content" class="streaming-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
<a-textarea v-model="inputMessage" :placeholder="isLoading ? 'AI正在<E6ADA3><E59CA8><EFBFBD>考中...' : '请输入您的问题...'"
|
||||
:auto-size="{ minRows: 1, maxRows: 4 }" :disabled="isLoading" @keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.shift.exact="handleShiftEnter" class="message-input" />
|
||||
<div class="input-actions">
|
||||
<a-button type="primary" :loading="isLoading" :disabled="!inputMessage.trim()" @click="sendMessage"
|
||||
class="send-button">
|
||||
<template #icon>
|
||||
<icon-send />
|
||||
</template>
|
||||
发送
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconRobot, IconUser, IconSend } from '@arco-design/web-vue/es/icon'
|
||||
import { chatAPI, type ChatMessage as APIChatMessage } from '@/api'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
const messages = ref<ChatMessage[]>([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '您好!我是AI助手,有什么可以帮助您的吗?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
|
||||
const inputMessage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const messagesContainer = ref<HTMLElement>()
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatMessageContent = (content: string) => {
|
||||
if (!content) return ''
|
||||
|
||||
const formatted = content
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
|
||||
console.log('格式化消息 - 原始:', content)
|
||||
console.log('格式化消息 - 结果:', formatted)
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const message = inputMessage.value.trim()
|
||||
if (!message || isLoading.value) return
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
messages.value.push(userMessage)
|
||||
inputMessage.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
await callChatAPI(message)
|
||||
} catch (error: any) {
|
||||
Message.error(error.message || '发送消息失败,请重试')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const callChatAPI = async (userMessage: string) => {
|
||||
console.log('开始API调用,用户消息:', userMessage)
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true
|
||||
}
|
||||
|
||||
messages.value.push(assistantMessage)
|
||||
const messageIndex = messages.value.length - 1
|
||||
scrollToBottom()
|
||||
|
||||
const apiMessages: APIChatMessage[] = messages.value
|
||||
.filter((msg, index) => !msg.isStreaming && index !== messageIndex)
|
||||
.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}))
|
||||
.slice(-10) // 保留最近10条消息以控制token数量
|
||||
|
||||
console.log('发送到API的消息:', apiMessages)
|
||||
|
||||
try {
|
||||
const response = await chatAPI.sendMessage(
|
||||
apiMessages,
|
||||
(chunk: string) => {
|
||||
console.log('收到chunk:', chunk)
|
||||
if (messages.value[messageIndex]) {
|
||||
messages.value[messageIndex].content += chunk
|
||||
}
|
||||
// 使用 nextTick 确保 DOM 更新
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.log('API调用完成,完整响应:', response)
|
||||
if (messages.value[messageIndex]) {
|
||||
messages.value[messageIndex].isStreaming = false
|
||||
messages.value[messageIndex].content = response
|
||||
}
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error)
|
||||
// 如果API调用失败,移除正在流式传输的消息
|
||||
if (messages.value[messageIndex] && !messages.value[messageIndex].content) {
|
||||
messages.value.splice(messageIndex, 1)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleShiftEnter = () => {
|
||||
inputMessage.value += '\n'
|
||||
}
|
||||
|
||||
const testAPIConnection = async () => {
|
||||
try {
|
||||
const isConnected = await chatAPI.testConnection()
|
||||
if (isConnected) {
|
||||
Message.success('Chat API 连接成功!')
|
||||
} else {
|
||||
Message.error('Chat API 连接失败,请检查配置')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API连接测试失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
testAPIConnection()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-chat-container {
|
||||
height: calc(100vh - 200px);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-card :deep(.arco-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #00b42a;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
scroll-behavior: smooth;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-wrapper.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.user .message-content {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user .message-avatar {
|
||||
background-color: #165dff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.assistant .message-avatar {
|
||||
background-color: #00b42a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.user .message-bubble {
|
||||
background-color: #165dff;
|
||||
color: white;
|
||||
border-color: #165dff;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-text :deep(.code-block) {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
border: 1px solid #e1e4e8;
|
||||
}
|
||||
|
||||
.message-text :deep(.inline-code) {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
border: 1px solid #e1e4e8;
|
||||
}
|
||||
|
||||
.message-text :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-text :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user .message-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.streaming-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #86909c;
|
||||
animation: streaming 1.4s infinite;
|
||||
}
|
||||
|
||||
.streaming-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.streaming-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes streaming {
|
||||
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-8px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
border-top: 1px solid #e5e6eb;
|
||||
padding-top: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message-input :deep(.arco-textarea) {
|
||||
resize: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ai-chat-container {
|
||||
height: calc(100vh - 180px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,211 @@
|
||||
<template>
|
||||
<div id="userLoginView">
|
||||
<h2 style="margin-bottom: 16px">用户登录</h2>
|
||||
<a-form
|
||||
style="max-width: 480px; margin: 0 auto"
|
||||
label-align="left"
|
||||
auto-label-width
|
||||
:model="form"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<a-form-item field="userAccount" label="账号">
|
||||
<a-input v-model="form.userAccount" placeholder="请输入账号" />
|
||||
</a-form-item>
|
||||
<a-form-item field="userPassword" tooltip="密码不少于 8 位" label="密码">
|
||||
<a-input-password
|
||||
v-model="form.userPassword"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" style="width: 120px">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">欢迎登录 AI OJ</h2>
|
||||
<p class="login-subtitle">AI Online Judge By MeowRain</p>
|
||||
|
||||
<a-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@submit="handleSubmit"
|
||||
layout="vertical"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item field="userAccount" label="账号" validate-trigger="blur">
|
||||
<a-input
|
||||
v-model="form.userAccount"
|
||||
placeholder="请输入账号"
|
||||
size="large"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-user />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="userPassword" label="密码" validate-trigger="blur">
|
||||
<a-input-password
|
||||
v-model="form.userPassword"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-lock />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="form-actions">
|
||||
<a-checkbox v-model="rememberMe">记住我</a-checkbox>
|
||||
<a-link>忘记密码?</a-link>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
size="large"
|
||||
:loading="loading"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="register-link">
|
||||
还没有账号?
|
||||
<a-link @click="goToRegister">立即注册</a-link>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from "vue";
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "@arco-design/web-vue";
|
||||
import { IconUser, IconLock } from "@arco-design/web-vue/es/icon";
|
||||
import { login } from "@/api/auth";
|
||||
import { setTokens } from "@/utils/token";
|
||||
import { useUserStore } from "@/store/user";
|
||||
import ACCESS_ENUM from "@/access/accessEnum";
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const form = reactive({
|
||||
userAccount: "",
|
||||
userPassword: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
console.log(form);
|
||||
const rules = {
|
||||
userAccount: [
|
||||
{ required: true, message: "请输入账号" },
|
||||
{ minLength: 4, message: "账号不能少于4位" },
|
||||
],
|
||||
userPassword: [
|
||||
{ required: true, message: "请输入密码" },
|
||||
{ minLength: 8, message: "密码不能少于8位" },
|
||||
],
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (data.errors) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await login({
|
||||
userAccount: form.userAccount,
|
||||
userPassword: form.userPassword,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
Message.success("登录成功!");
|
||||
|
||||
// 存储 token
|
||||
setTokens(response.data.accessToken, response.data.refreshToken);
|
||||
|
||||
// 更新用户状态
|
||||
userStore.updateUserLoginStatus({
|
||||
userName: response.data.userAccount,
|
||||
userRole: ACCESS_ENUM.USER,
|
||||
});
|
||||
|
||||
// 跳转到首页
|
||||
router.push("/home");
|
||||
} else {
|
||||
Message.error(response.message || "登录失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("登录失败:", error);
|
||||
Message.error(error.response?.data?.message || "登录失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToRegister = () => {
|
||||
router.push("/user/register");
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#userLoginView {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.arco-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.arco-form-item-label-col) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.arco-form-item-label) {
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
249
src/views/user/UserRegisterView.vue
Normal file
249
src/views/user/UserRegisterView.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div id="userRegisterView">
|
||||
<div class="register-container">
|
||||
<div class="register-card">
|
||||
<h2 class="register-title">注册 AI OJ</h2>
|
||||
<p class="register-subtitle">AI Online Judge By MeowRain</p>
|
||||
|
||||
<a-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@submit="handleSubmit"
|
||||
layout="vertical"
|
||||
class="register-form"
|
||||
>
|
||||
<a-form-item field="userAccount" label="账号" validate-trigger="blur">
|
||||
<a-input
|
||||
v-model="form.userAccount"
|
||||
placeholder="请输入账号(4-16位字符)"
|
||||
size="large"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-user />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="userPassword" label="密码" validate-trigger="blur">
|
||||
<a-input-password
|
||||
v-model="form.userPassword"
|
||||
placeholder="请输入密码(至少8位)"
|
||||
size="large"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-lock />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
field="confirmPassword"
|
||||
label="确认密码"
|
||||
validate-trigger="blur"
|
||||
>
|
||||
<a-input-password
|
||||
v-model="form.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-lock />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-checkbox v-model="agreeTerms">
|
||||
我已阅读并同意
|
||||
<a-link>《用户协议》</a-link>
|
||||
和
|
||||
<a-link>《隐私政策》</a-link>
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
size="large"
|
||||
:loading="loading"
|
||||
:disabled="!agreeTerms"
|
||||
>
|
||||
注册
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="login-link">
|
||||
已有账号?
|
||||
<a-link @click="goToLogin">立即登录</a-link>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "@arco-design/web-vue";
|
||||
import { IconUser, IconLock } from "@arco-design/web-vue/es/icon";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = reactive({
|
||||
userAccount: "",
|
||||
userPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
userAccount: [
|
||||
{ required: true, message: "请输入账号" },
|
||||
{ minLength: 4, message: "账号不能少于4位" },
|
||||
{ maxLength: 16, message: "账号不能超过16位" },
|
||||
{
|
||||
match: /^[a-zA-Z0-9_]+$/,
|
||||
message: "账号只能包含字母、数字和下划线",
|
||||
},
|
||||
],
|
||||
userPassword: [
|
||||
{ required: true, message: "请输入密码" },
|
||||
{ minLength: 8, message: "密码不能少于8位" },
|
||||
{
|
||||
match: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
message: "密码必须包含大小写字母和数字",
|
||||
},
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "请再次输入密码" },
|
||||
{
|
||||
validator: (value: string, callback: (error?: string) => void) => {
|
||||
if (value !== form.userPassword) {
|
||||
callback("两次输入的密码不一致");
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const agreeTerms = ref(false);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (data.errors) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreeTerms.value) {
|
||||
Message.warning("请先阅读并同意用户协议和隐私政策");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// TODO: 调用注册 API
|
||||
// 目前后端文档中没有注册接口,这里模拟注册成功
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
Message.success("注册成功!请登录");
|
||||
|
||||
// 跳转到登录页
|
||||
router.push("/user/login");
|
||||
|
||||
// 实际实现时应该调用后端注册接口:
|
||||
// const response = await register({
|
||||
// userAccount: form.userAccount,
|
||||
// userPassword: form.userPassword,
|
||||
// });
|
||||
//
|
||||
// if (response.success) {
|
||||
// Message.success("注册成功!请登录");
|
||||
// router.push("/user/login");
|
||||
// } else {
|
||||
// Message.error(response.message || "注册失败");
|
||||
// }
|
||||
} catch (error: any) {
|
||||
console.error("注册失败:", error);
|
||||
Message.error(error.response?.data?.message || "注册失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push("/user/login");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#userRegisterView {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.register-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.register-subtitle {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
:deep(.arco-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.arco-form-item-label-col) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.arco-form-item-label) {
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
:deep(.arco-checkbox) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user