第三部分:前端开发(Vue 3)
3.1 创建Vue项目
# 创建Vue 3项目
npm create vue@latest travel-ui
# 进入项目目录
cd travel-ui
# 安装依赖
npm install
# 安装必要依赖
npm install element-plus axios vue-router@4 pinia
3.2 项目目录结构
travel-ui/
├── index.html
├── vite.config.js
├── package.json
├── src/
│ ├── api/ # API接口
│ │ ├── auth.js
│ │ ├── tour.js
│ │ ├── order.js
│ │ └── favorite.js
│ ├── assets/ # 静态资源
│ ├── components/ # 通用组件
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── stores/ # Pinia状态管理
│ │ └── user.js
│ ├── utils/ # 工具函数
│ │ └── request.js
│ ├── views/ # 页面组件
│ │ ├── Home.vue
│ │ ├── Login.vue
│ │ ├── Register.vue
│ │ ├── TourDetail.vue
│ │ ├── Tours.vue
│ │ ├── OrderList.vue
│ │ ├── Favorites.vue
│ │ └── Profile.vue
│ ├── App.vue
│ └── main.js
3.3 Axios请求封装
// src/utils/request.js
import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
/**
* 创建axios实例
* baseURL指向后端API地址
* timeout设置请求超时时间
*/
const request = axios.create({
baseURL: '/api',
timeout: 10000
});
/**
* 请求拦截器
* 在发送请求前自动添加JWT令牌
*/
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
/**
* 响应拦截器
* 统一处理响应数据和错误
*/
request.interceptors.response.use(
response => {
const res = response.data;
if (res.code === 200 || res.code === 201) {
return res;
} else if (res.code === 401) {
ElMessage.error('登录已过期,请重新登录');
localStorage.removeItem('token');
localStorage.removeItem('user');
router.push('/login');
return Promise.reject(res);
} else if (res.code === 403) {
ElMessage.error(res.message || '权限不足');
return Promise.reject(res);
} else {
ElMessage.error(res.message || '请求失败');
return Promise.reject(res);
}
},
error => {
if (error.response) {
const status = error.response.status;
if (status === 404) {
ElMessage.error('请求的资源不存在');
} else if (status === 500) {
ElMessage.error('服务器内部错误');
} else {
ElMessage.error(error.message || '网络错误');
}
} else if (error.request) {
ElMessage.error('网络连接失败');
} else {
ElMessage.error(error.message);
}
return Promise.reject(error);
}
);
export default request;
3.4 API接口定义
// src/api/auth.js
import request from '@/utils/request';
export const register = (data) => request.post('/user/register', data);
export const login = (data) => request.post('/user/login', data);
export const getUserProfile = () => request.get('/user/profile');
export const changePassword = (data) => request.put('/user/password', data);
export const logout = () => request.post('/user/logout');
// src/api/tour.js
import request from '@/utils/request';
export const getTours = (params) => request.get('/tours', { params });
export const getTourDetail = (slug) => request.get(`/tours/${slug}`);
export const getCategories = () => request.get('/tours/categories/list');
// src/api/order.js
import request from '@/utils/request';
export const createOrder = (data) => request.post('/orders', data);
export const getOrders = (params) => request.get('/orders', { params });
export const getOrderDetail = (id) => request.get(`/orders/${id}`);
export const cancelOrder = (id) => request.put(`/orders/${id}/cancel`);
// src/api/favorite.js
import request from '@/utils/request';
export const getFavorites = (params) => request.get('/favorites', { params });
export const addFavorite = (tourId) => request.post(`/favorites/${tourId}`);
export const removeFavorite = (tourId) => request.delete(`/favorites/${tourId}`);
export const checkFavorite = (tourId) => request.get(`/favorites/check/${tourId}`);
3.5 路由配置
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false, title: '登录' }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
meta: { requiresAuth: false, title: '注册' }
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页' }
},
{
path: '/tours',
name: 'Tours',
component: () => import('@/views/Tours.vue'),
meta: { title: '旅游线路' }
},
{
path: '/tour/:slug',
name: 'TourDetail',
component: () => import('@/views/TourDetail.vue'),
meta: { title: '线路详情' }
},
{
path: '/orders',
name: 'OrderList',
component: () => import('@/views/OrderList.vue'),
meta: { title: '我的订单' }
},
{
path: '/favorites',
name: 'Favorites',
component: () => import('@/views/Favorites.vue'),
meta: { title: '我的收藏' }
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: { title: '个人中心' }
}
]
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next('/login');
} else if ((to.path === '/login' || to.path === '/register') && token) {
next('/');
} else {
document.title = `旅游管理系统 - ${to.meta.title || ''}`;
next();
}
});
export default router;
3.6 Pinia状态管理
// src/stores/user.js
import { defineStore } from 'pinia';
import { login, register, getUserProfile } from '@/api/auth';
export const useUserStore = defineStore('user', {
state: () => ({
userId: localStorage.getItem('userId') || null,
username: localStorage.getItem('username') || '',
name: localStorage.getItem('name') || '',
avatar: localStorage.getItem('avatar') || '',
role: localStorage.getItem('role') || '',
token: localStorage.getItem('token') || ''
}),
getters: {
isLoggedIn: (state) => !!state.token,
isAdmin: (state) => state.role === 'admin'
},
actions: {
async login(loginData) {
const res = await login(loginData);
if (res.code === 200) {
this.setUserInfo(res.data);
await this.fetchUserProfile();
return res;
}
return res;
},
async register(registerData) {
const res = await register(registerData);
if (res.code === 201) {
this.setUserInfo(res.data);
return res;
}
return res;
},
setUserInfo(userInfo) {
this.userId = userInfo.user_id;
this.username = userInfo.username;
this.name = userInfo.name || userInfo.username;
this.role = userInfo.role;
this.token = userInfo.token;
localStorage.setItem('userId', userInfo.user_id);
localStorage.setItem('username', userInfo.username);
localStorage.setItem('name', userInfo.name || userInfo.username);
localStorage.setItem('role', userInfo.role);
localStorage.setItem('token', userInfo.token);
},
async fetchUserProfile() {
const res = await getUserProfile();
if (res.code === 200) {
this.name = res.data.name || this.username;
this.avatar = res.data.avatar;
localStorage.setItem('name', this.name);
if (res.data.avatar) localStorage.setItem('avatar', res.data.avatar);
}
},
logout() {
this.userId = null;
this.username = '';
this.name = '';
this.avatar = '';
this.role = '';
this.token = '';
localStorage.removeItem('userId');
localStorage.removeItem('username');
localStorage.removeItem('name');
localStorage.removeItem('avatar');
localStorage.removeItem('role');
localStorage.removeItem('token');
// 调用登出接口(可选)
import('@/api/auth').then(({ logout }) => logout());
}
}
});
3.7 登录页面
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="login-header">
<h2>旅游管理系统</h2>
<p>登录后开始您的旅程</p>
</div>
</template>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" @click="handleLogin" :loading="loading" block>登录</el-button>
</el-form-item>
<div class="login-footer">
还没有账号? <router-link to="/register">立即注册</router-link>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
const router = useRouter();
const userStore = useUserStore();
const formRef = ref();
const loading = ref(false);
const form = reactive({
username: '',
password: ''
});
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
};
const handleLogin = async () => {
await formRef.value.validate();
loading.value = true;
try {
const res = await userStore.login(form);
if (res.code === 200) {
ElMessage.success('登录成功');
router.push('/');
}
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card { width: 400px; border-radius: 16px; }
.login-header { text-align: center; }
.login-header p { color: #666; margin-top: 8px; }
.login-footer { text-align: center; margin-top: 16px; }
.login-footer a { color: #667eea; text-decoration: none; }
</style>
3.8 注册页面
<template>
<div class="register-container">
<el-card class="register-card">
<template #header>
<div class="register-header">
<h2>用户注册</h2>
<p>加入我们,开启精彩旅程</p>
</div>
</template>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" @click="handleRegister" :loading="loading" block>注册</el-button>
</el-form-item>
<div class="register-footer">
已有账号? <router-link to="/login">立即登录</router-link>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
const router = useRouter();
const userStore = useUserStore();
const formRef = ref();
const loading = ref(false);
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const validateConfirmPassword = (rule, value, callback) => {
if (value !== form.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
};
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度3-20位', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度6-20位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
};
const handleRegister = async () => {
await formRef.value.validate();
loading.value = true;
try {
const res = await userStore.register(form);
if (res.code === 201) {
ElMessage.success('注册成功');
router.push('/');
}
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.register-card { width: 450px; border-radius: 16px; }
.register-header { text-align: center; }
.register-header p { color: #666; margin-top: 8px; }
.register-footer { text-align: center; margin-top: 16px; }
.register-footer a { color: #667eea; text-decoration: none; }
</style>
3.9 首页
<template>
<div class="home">
<!-- 搜索栏 -->
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索目的地、线路名称..."
size="large"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
</div>
<!-- 分类导航 -->
<div class="categories">
<div class="category-item" :class="{ active: selectedCategory === null }" @click="selectCategory(null)">
全部
</div>
<div
v-for="cat in categories"
:key="cat.id"
class="category-item"
:class="{ active: selectedCategory === cat.id }"
@click="selectCategory(cat.id)"
>
{
{ cat.name }}
</div>
</div>
<!-- 推荐线路 -->
<div class="section">
<div class="section-header">
<h2>热门推荐</h2>
<router-link to="/tours" class="more">查看更多 →</router-link>
</div>
<div class="tour-grid">
<div v-for="tour in featuredTours" :key="tour.id" class="tour-card" @click="goToDetail(tour.slug)">
<div class="tour-image">
<img :src="tour.main_image || '/default-image.jpg'" :alt="tour.name">
<span class="tour-price">¥{
{ tour.price }}起</span>
</div>
<div class="tour-info">
<h3>{
{ tour.name }}</h3>
<div class="tour-meta">
<span><el-icon><Calendar /></el-icon> {
{ tour.days }}天</span>
<span><el-icon><Location /></el-icon> {
{ tour.departure_city }}出发</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Search, Calendar, Location } from '@element-plus/icons-vue';
import { getTours, getCategories } from '@/api/tour';
const router = useRouter();
const searchKeyword = ref('');
const categories = ref([]);
const selectedCategory = ref(null);
const featuredTours = ref([]);
const loadCategories = async () => {
const res = await getCategories();
if (res.code === 200) {
categories.value = res.data;
}
};
const loadFeaturedTours = async () => {
const res = await getTours({ is_featured: 1, limit: 6 });
if (res.code === 200) {
featuredTours.value = res.data.tours;
}
};
const selectCategory = (id) => {
selectedCategory.value = id;
if (id) {
router.push({ path: '/tours', query: { category_id: id } });
} else {
router.push('/tours');
}
};
const handleSearch = () => {
if (searchKeyword.value) {
router.push({ path: '/tours', query: { keyword: searchKeyword.value } });
}
};
const goToDetail = (slug) => {
router.push(`/tour/${slug}`);
};
onMounted(() => {
loadCategories();
loadFeaturedTours();
});
</script>
<style scoped>
.home { max-width: 1200px; margin: 0 auto; padding: 20px; }
.search-section { margin-bottom: 30px; }
.categories { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 40px; }
.category-item { padding: 8px 24px; border-radius: 30px; background: #f5f5f5; cursor: pointer; transition: all 0.3s; }
.category-item:hover, .category-item.active { background: #667eea; color: white; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.more { color: #667eea; text-decoration: none; }
.tour-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; }
.tour-card { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.1); cursor: pointer; transition: transform 0.3s; }
.tour-card:hover { transform: translateY(-5px); }
.tour-image { position: relative; height: 200px; overflow: hidden; }
.tour-image img { width: 100%; height: 100%; object-fit: cover; }
.tour-price { position: absolute; bottom: 12px; right: 12px; background: rgba(0,0,0,0.7); color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px; font-weight: bold; }
.tour-info { padding: 16px; }
.tour-info h3 { margin: 0 0 8px; font-size: 18px; }
.tour-meta { display: flex; gap: 16px; color: #666; font-size: 13px; }
</style>