feat(global-header): 重构全局头部组件及样式优化

- 完全重写 GlobalHeader 组件结构,改用 div 语义及自定义样式替代原antd a-menu
- 新增 Logo 区域包含图标和标题,支持点击跳转首页功能
- 实现导航菜单动态渲染,根据路由权限过滤显示
- 用户区域支持未登录和已登录两种状态切换
- 未登录时展示登录、注册按钮,支持路由跳转
- 已登录时显示用户头像、用户名及下拉菜单,包含个人中心、设置和退出登录操作
- 引入 Arco Design 图标组件优化视觉表现
- 完善登出流程,清理本地Token并提示用户
- 优化响应式布局和交互体验,提升用户界面整体一致性与可用性
This commit is contained in:
2025-12-14 16:19:28 +08:00
parent a13dae85b3
commit 05669a6570
24 changed files with 17378 additions and 115 deletions

201
src/api/aiChat.ts Normal file
View 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
View 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
View 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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -11,4 +11,4 @@ const pinia = createPinia();
app.use(router);
app.use(ArcoVue);
app.use(pinia)
app.mount("#app");
app.mount("#app");

View File

@@ -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}`;

View File

@@ -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
View 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();
};

View File

@@ -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
View 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>

View File

@@ -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>

View 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>