全栈(PHP + Vue + MySQL)开发旅游管理系统教程(三)

简介: 教程来源 http://oplhc.cn 本项目基于Vue 3构建旅游管理系统前端,采用Vite+Element Plus+Pinia+Vue Router架构。涵盖完整目录结构、Axios统一请求封装(含JWT鉴权与错误处理)、模块化API接口、路由守卫、用户状态管理及响应式页面实现,代码规范,开箱即用。

第三部分:前端开发(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 项目目录结构

http://fndvx.cn

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>
相关文章
|
8天前
|
缓存 人工智能 自然语言处理
我对比了8个Claude API中转站,踩了不少坑,总结给你
本文是个人开发者耗时1周实测的8大Claude中转平台横向评测,聚焦Claude Code真实体验:以加权均价(¥/M token)、内部汇率、缓存支持、模型真实性及稳定性为核心指标。
3425 20
|
20天前
|
人工智能 自然语言处理 安全
Claude Code 全攻略:命令大全 + 实战工作流(建议收藏)
本文介绍了Claude Code终端AI助手的使用指南,主要内容包括:1)常用命令如版本查看、项目启动和更新;2)三种工作模式切换及界面说明;3)核心功能指令速查表,包含初始化、压缩对话、清除历史等操作;4)详细解析了/init、/help、/clear、/compact、/memory等关键命令的使用场景和语法。文章通过丰富的界面截图和场景示例,帮助开发者快速掌握如何通过命令行和交互界面高效使用Claude Code进行项目开发,特别强调了CLAUDE.md文件作为项目知识库的核心作用。
17975 60
Claude Code 全攻略:命令大全 + 实战工作流(建议收藏)
|
1天前
|
SQL 人工智能 弹性计算
阿里云发布 Agentic NDR,威胁检测与响应进入智能体时代
欢迎前往阿里云云防火墙控制台体验!
1156 2
|
4天前
|
人工智能 JSON BI
DeepSeek V4 来了!超越 Claude Sonnet 4.5,赶紧对接 Claude Code 体验一把
JeecgBoot AI专题研究 把 Claude Code 接入 DeepSeek V4Pro 的真实体验与避坑记录 本文记录我将 Claude Code 对接 DeepSeek 最新模型(V4Pro)后的真实体验,测试了 Skills 自动化查询和积木报表 AI 建表两个场景——有惊喜,也踩
1851 8
|
15天前
|
人工智能 JavaScript Ubuntu
低成本搭建AIP自动化写作系统:Hermes保姆级使用教程,长文和逐步实操贴图
我带着怀疑的态度,深度使用了几天,聚焦微信公众号AIP自动化写作场景,写出来的几篇文章,几乎没有什么修改,至少合乎我本人的意愿,而且排版风格,也越来越完善,同样是起码过得了我自己这一关。 这个其实OpenClaw早可以实现了,但是目前我觉得最大的区别是,Hermes会自主总结提炼,并更新你的写作技能。 相信就冲这一点,就值得一试。 这篇帖子主要就Hermes部署使用,作一个非常详细的介绍,几乎一步一贴图。 关于Hermes,无论你赞成哪种声音,我希望都是你自己动手行动过,发自内心的选择!
3170 29
|
3天前
|
人工智能 缓存 BI
Claude Code + DeepSeek V4-Pro 真实评测:除了贵,没别的毛病
JeecgBoot AI专题研究 把 Claude Code 接入 DeepSeek V4Pro,跑完 Skills —— OA 审批、大屏、报表、部署 5 大实战场景后的真实体验 ![](https://oscimg.oschina.net/oscnet/up608d34aeb6bafc47f
1476 3
Claude Code + DeepSeek V4-Pro 真实评测:除了贵,没别的毛病
|
4天前
|
机器学习/深度学习 缓存 测试技术
DeepSeek-V4开源:百万上下文,Agent能力比肩顶级闭源模型
DeepSeek-V4正式开源!含V4-Pro(1.6T参数)与V4-Flash(284B参数)双版本,均支持百万token上下文。首创混合注意力架构,Agent能力、世界知识与推理性能全面领先开源模型,数学/代码评测比肩顶级闭源模型。
1734 6
|
5天前
|
人工智能 测试技术 API
阿里Qwen3.6-27B正式开源:网友直呼“太牛了”!
阿里云千问3.6系列重磅开源Qwen3.6-27B稠密大模型!官网:https://t.aliyun.com/U/JbblVp 仅270亿参数,编程能力媲美千亿模型,在SWE-bench等权威基准中表现卓越。支持多模态理解、本地部署及OpenClaw等智能体集成,已开放Hugging Face与ModelScope下载。