diff --git a/src/api/appointment.ts b/src/api/appointment.ts new file mode 100644 index 0000000..1c780ee --- /dev/null +++ b/src/api/appointment.ts @@ -0,0 +1,41 @@ +import request from '@/utils/request' +import type { AppointmentInfo } from '@/types/appointment' + +export const appointmentApi = { + // 创建预约 + create(data: { + title: string + gameId: string + groupId: string + startTime: string + endTime: string + maxParticipants: number + }) { + return request.post('/appointments', data) + }, + + // 获取我的预约列表 + getMyAppointments(params?: { status?: string }) { + return request.get('/appointments/my', { params }) + }, + + // 获取预约详情 + getAppointmentDetail(id: string) { + return request.get(`/appointments/${id}`) + }, + + // 加入预约 + join(id: string) { + return request.post(`/appointments/${id}/join`) + }, + + // 退出预约 + leave(id: string) { + return request.delete(`/appointments/${id}/leave`) + }, + + // 取消预约 + cancel(id: string) { + return request.delete(`/appointments/${id}`) + } +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..6c92c2a --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,38 @@ +import request from '@/utils/request' + +export interface LoginParams { + account: string + password: string +} + +export interface RegisterParams { + username: string + password: string + email?: string + phone?: string +} + +export interface LoginResponse { + user: import('@/types/user').UserInfo + accessToken: string + refreshToken: string +} + +export const authApi = { + // 登录 + login(params: LoginParams) { + return request.post('/auth/login', params) + }, + + // 注册 + register(params: RegisterParams) { + return request.post('/auth/register', params) + }, + + // 刷新令牌 + refreshToken(refreshToken: string) { + return request.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { + refreshToken + }) + } +} diff --git a/src/api/game.ts b/src/api/game.ts new file mode 100644 index 0000000..70629d2 --- /dev/null +++ b/src/api/game.ts @@ -0,0 +1,24 @@ +import request from '@/utils/request' +import type { GameInfo } from '@/types/game' + +export const gameApi = { + // 获取游戏列表 + getGames(params?: { page?: number; limit?: number }) { + return request.get('/games', { params }) + }, + + // 获取热门游戏 + getPopularGames(limit: number = 10) { + return request.get('/games/popular', { params: { limit } }) + }, + + // 获取游戏详情 + getGameDetail(id: string) { + return request.get(`/games/${id}`) + }, + + // 搜索游戏 + searchGames(keyword: string) { + return request.get('/games/search', { params: { keyword } }) + } +} diff --git a/src/api/group.ts b/src/api/group.ts new file mode 100644 index 0000000..7b7ae2b --- /dev/null +++ b/src/api/group.ts @@ -0,0 +1,39 @@ +import request from '@/utils/request' +import type { GroupInfo, CreateGroupParams } from '@/types/group' + +export const groupApi = { + // 创建小组 + create(data: CreateGroupParams) { + return request.post('/groups', data) + }, + + // 加入小组 + join(data: { groupId: string; nickname?: string }) { + return request.post('/groups/join', data) + }, + + // 获取我的小组列表 + getMyGroups() { + return request.get('/groups/my') + }, + + // 获取小组详情 + getGroupDetail(id: string) { + return request.get(`/groups/${id}`) + }, + + // 更新小组信息 + updateGroup(id: string, data: Partial) { + return request.put(`/groups/${id}`, data) + }, + + // 退出小组 + leaveGroup(id: string) { + return request.delete(`/groups/${id}/leave`) + }, + + // 解散小组 + dissolveGroup(id: string) { + return request.delete(`/groups/${id}`) + } +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..95af8f6 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,6 @@ +// 导出所有 API 模块 +export * from './auth' +export * from './user' +export * from './group' +export * from './game' +export * from './appointment' diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..e60471e --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,24 @@ +import request from '@/utils/request' +import type { UserInfo } from '@/types/user' + +export const userApi = { + // 获取当前用户信息 + getProfile() { + return request.get('/users/me') + }, + + // 获取指定用户信息 + getUserInfo(id: string) { + return request.get(`/users/${id}`) + }, + + // 更新用户信息 + updateProfile(data: Partial) { + return request.put('/users/me', data) + }, + + // 修改密码 + changePassword(data: { oldPassword: string; newPassword: string }) { + return request.put('/users/me/password', data) + } +} diff --git a/src/main.ts b/src/main.ts index e7dc9d5..fd10fb7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,24 @@ import { createApp } from 'vue' import App from './App.vue' +import router from './router' +import { createPinia } from 'pinia' + +// Element Plus +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' // 样式 import './assets/styles/main.css' const app = createApp(App) +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) app.mount('#app') diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..690f458 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,106 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/auth/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/register', + name: 'Register', + component: () => import('@/views/auth/Register.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/components/layout/MainLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Home', + component: () => import('@/views/home/index.vue') + }, + { + path: 'user/profile', + name: 'UserProfile', + component: () => import('@/views/user/Profile.vue') + }, + { + path: 'user/settings', + name: 'UserSettings', + component: () => import('@/views/user/Settings.vue') + }, + { + path: 'groups', + name: 'GroupList', + component: () => import('@/views/group/List.vue') + }, + { + path: 'groups/create', + name: 'GroupCreate', + component: () => import('@/views/group/Create.vue') + }, + { + path: 'groups/:id', + name: 'GroupDetail', + component: () => import('@/views/group/Detail.vue') + }, + { + path: 'games', + name: 'GameList', + component: () => import('@/views/game/List.vue') + }, + { + path: 'games/:id', + name: 'GameDetail', + component: () => import('@/views/game/Detail.vue') + }, + { + path: 'appointments', + name: 'AppointmentList', + component: () => import('@/views/appointment/List.vue') + }, + { + path: 'appointments/create', + name: 'AppointmentCreate', + component: () => import('@/views/appointment/Create.vue') + }, + { + path: 'points/ranking', + name: 'PointsRanking', + component: () => import('@/views/points/Ranking.vue') + }, + { + path: 'honors', + name: 'Honors', + component: () => import('@/views/honor/Timeline.vue') + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('access_token') + const requiresAuth = to.meta.requiresAuth !== false + + if (requiresAuth && !token) { + next({ + name: 'Login', + query: { redirect: to.fullPath } + }) + } else { + next() + } +}) + +export default router diff --git a/src/stores/app.ts b/src/stores/app.ts new file mode 100644 index 0000000..4cd9459 --- /dev/null +++ b/src/stores/app.ts @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useAppStore = defineStore('app', () => { + const theme = ref<'light' | 'dark'>('light') + const sidebarCollapsed = ref(false) + const loading = ref(false) + + function toggleTheme() { + theme.value = theme.value === 'light' ? 'dark' : 'light' + } + + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + function setLoading(value: boolean) { + loading.value = value + } + + return { + theme, + sidebarCollapsed, + loading, + toggleTheme, + toggleSidebar, + setLoading + } +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..9120136 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '@/api/auth' +import { storage } from '@/utils/storage' +import type { UserInfo } from '@/types/user' + +export const useAuthStore = defineStore('auth', () => { + // State + const userInfo = ref(null) + const accessToken = ref('') + const refreshToken = ref('') + + // Getters + const isLoggedIn = computed(() => !!accessToken.value && !!userInfo.value) + + // Actions + async function login(account: string, password: string) { + const data = await authApi.login({ account, password }) + userInfo.value = data.user + accessToken.value = data.accessToken + refreshToken.value = data.refreshToken + + // 保存到 localStorage + storage.setToken(data.accessToken, data.refreshToken) + storage.setUserInfo(data.user) + } + + async function register(params: { + username: string + password: string + email?: string + phone?: string + }) { + const data = await authApi.register(params) + userInfo.value = data.user + accessToken.value = data.accessToken + refreshToken.value = data.refreshToken + + storage.setToken(data.accessToken, data.refreshToken) + storage.setUserInfo(data.user) + } + + function logout() { + userInfo.value = null + accessToken.value = '' + refreshToken.value = '' + + storage.clear() + } + + function initAuth() { + const token = storage.getAccessToken() + const refresh = storage.getRefreshToken() + const user = storage.getUserInfo() + + if (token) { + accessToken.value = token + } + if (refresh) { + refreshToken.value = refresh + } + if (user) { + userInfo.value = user + } + } + + return { + userInfo, + accessToken, + refreshToken, + isLoggedIn, + login, + register, + logout, + initAuth + } +}) diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..9b6268e --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,62 @@ +/** + * 格式化日期时间 + */ +export function formatDateTime(date: string | Date): string { + const d = new Date(date) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hours = String(d.getHours()).padStart(2, '0') + const minutes = String(d.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +/** + * 格式化日期 + */ +export function formatDate(date: string | Date): string { + const d = new Date(date) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * 格式化时间 + */ +export function formatTime(date: string | Date): string { + const d = new Date(date) + const hours = String(d.getHours()).padStart(2, '0') + const minutes = String(d.getMinutes()).padStart(2, '0') + return `${hours}:${minutes}` +} + +/** + * 格式化相对时间 + */ +export function formatRelativeTime(date: string | Date): string { + const d = new Date(date) + const now = new Date() + const diff = now.getTime() - d.getTime() + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}天前` + if (hours > 0) return `${hours}小时前` + if (minutes > 0) return `${minutes}分钟前` + return '刚刚' +} + +/** + * 格式化文件大小 + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i] +} diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..511b3de --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,54 @@ +import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios' +import { ElMessage } from 'element-plus' +import { API_BASE_URL, API_TIMEOUT } from '@/constants/config' + +// 创建 axios 实例 +const service: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: API_TIMEOUT, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 +service.interceptors.request.use( + (config) => { + // 从 localStorage 获取 token + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +service.interceptors.response.use( + (response: AxiosResponse) => { + const { code, message, data } = response.data + + if (code === 0) { + return data + } else { + ElMessage.error(message || '请求失败') + return Promise.reject(new Error(message)) + } + }, + (error: AxiosError) => { + if (error.response?.status === 401) { + ElMessage.error('登录已过期,请重新登录') + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + } else { + ElMessage.error(error.message || '网络错误') + } + return Promise.reject(error) + } +) + +export default service diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..c72e417 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,66 @@ +import { STORAGE_KEYS } from '@/constants/config' + +/** + * 本地存储工具 + */ +export const storage = { + /** + * 设置 token + */ + setToken(accessToken: string, refreshToken: string) { + localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken) + localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken) + }, + + /** + * 获取 access token + */ + getAccessToken(): string | null { + return localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN) + }, + + /** + * 获取 refresh token + */ + getRefreshToken(): string | null { + return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN) + }, + + /** + * 清除 token + */ + clearToken() { + localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN) + localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN) + }, + + /** + * 设置用户信息 + */ + setUserInfo(userInfo: any) { + localStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(userInfo)) + }, + + /** + * 获取用户信息 + */ + getUserInfo(): any | null { + const info = localStorage.getItem(STORAGE_KEYS.USER_INFO) + return info ? JSON.parse(info) : null + }, + + /** + * 清除用户信息 + */ + clearUserInfo() { + localStorage.removeItem(STORAGE_KEYS.USER_INFO) + }, + + /** + * 清除所有数据 + */ + clear() { + this.clearToken() + this.clearUserInfo() + } +} diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000..6de4198 --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,42 @@ +/** + * 验证邮箱 + */ +export function validateEmail(email: string): boolean { + const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return reg.test(email) +} + +/** + * 验证手机号 + */ +export function validatePhone(phone: string): boolean { + const reg = /^1[3-9]\d{9}$/ + return reg.test(phone) +} + +/** + * 验证用户名 (3-20位字母数字下划线) + */ +export function validateUsername(username: string): boolean { + const reg = /^[a-zA-Z0-9_]{3,20}$/ + return reg.test(username) +} + +/** + * 验证密码 (6-20位) + */ +export function validatePassword(password: string): boolean { + return password.length >= 6 && password.length <= 20 +} + +/** + * 验证URL + */ +export function validateURL(url: string): boolean { + try { + new URL(url) + return true + } catch { + return false + } +}