需求分析与技术选型
微光电子书屋需求分析
本系统需要用户管理,电子书管理,电子书阅读三个基础功能。
- 用户管理
注册登录
角色权限
电子书管理
书籍信息
书架管理
管理员书籍上传
电子书阅读
PDF阅读
EPUB阅读
微光电子书屋技术选型
前端:Vue3 + Vite + Element Plus
接口文档:springdoc
后端:Spring Boot + MySQL + MyBatis-Plus
数据库
用户表-users
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', `userName` varchar(256) NULL COMMENT '用户昵称', `userAccount` varchar(256) NULL COMMENT '账号', `avatarUrl` varchar(1024) NULL COMMENT '用户头像', `gender` tinyint NULL COMMENT '性别', `userPassword` varchar(512) NOT NULL COMMENT '密码', `userStatus` int DEFAULT '0' NULL COMMENT '状态 0-正常', `createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '创建时间', `updateTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除', `userRole` tinyint DEFAULT '0' NULL COMMENT '用户角色 0-普通用户 1-管理员 2-VIP', UNIQUE KEY `uk_user_account` (`userAccount`), PRIMARY KEY (`id`) ) COMMENT='用户表';
|
书籍信息表-books
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| DROP TABLE IF EXISTS `books`; CREATE TABLE `books` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', `title` VARCHAR(255) NOT NULL COMMENT '书名', `author` VARCHAR(100) NOT NULL COMMENT '作者', `isbn` VARCHAR(20) UNIQUE COMMENT 'ISBN号(可选)', `coverUrl` VARCHAR(255) DEFAULT NULL COMMENT '封面图路径', `description` TEXT COMMENT '简介', `filePath` VARCHAR(255) NOT NULL COMMENT 'EPUB/PDF文件在服务器的相对路径', `filePize` BIGINT DEFAULT '0' COMMENT '文件大小(字节)', format ENUM('epub', 'pdf') NOT NULL DEFAULT 'epub' COMMENT '格式', `bookStatus` TINYINT DEFAULT '1' COMMENT '状态:1=正常,0=下架', `createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '创建时间', `updateTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) COMMENT='书籍信息表';
|
用户书架-user_bookshelves
1 2 3 4 5 6 7 8 9 10 11 12
| DROP TABLE IF EXISTS `user_bookshelf`; CREATE TABLE `user_bookshelf` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `userId` bigint NOT NULL COMMENT '用户ID', `bookId` bigint NOT NULL COMMENT '书籍ID', `createTime` datetime DEFAULT CURRENT_TIMESTAMP NULL COMMENT '加入书架时间', `isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除:0-未删除 1-已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book` (`userId`, `bookId`), INDEX `idx_userId` (`userId`), INDEX `idx_bookId` (`bookId`) ) COMMENT='用户书架表';
|
功能开发中。。。
阅读进度表-reading_progresses
1 2 3 4 5 6 7 8 9 10 11 12 13
| DROP TABLE IF EXISTS `reading_progress`; CREATE TABLE `reading_progress` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `userId` bigint NOT NULL COMMENT '用户ID', `bookId` bigint NOT NULL COMMENT '书籍ID', `currentLocation` varchar(1024) NOT NULL COMMENT '当前位置(EPUB CFI 或 PDF 页码)', `lastReadTime` datetime DEFAULT CURRENT_TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '最后阅读时间', `isDelete` tinyint DEFAULT '0' NOT NULL COMMENT '是否删除:0-未删除 1-已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book_progress` (`userId`, `bookId`), INDEX `idx_userId` (`userId`), INDEX `idx_bookId` (`bookId`) ) COMMENT='阅读进度表';
|
前端
初始化
npm create vue@latest
选择功能:
Typescript + Router + Pinia + Eslint + Prettier
引入组件库 Element Plus 2.11.9
npm install element-plus --save
完整引入 main.ts
1 2 3 4
| import ElementPlus from 'element-plus' import 'element-plus/dist/index.css'
app.use(ElementPlus,{ size: 'small', zIndex: 3000 })
|
配置vite代理
vite.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| export default defineConfig({ plugins: [ vue(), vueDevTools(), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, secure: false, }, }, }, define: { 'import.meta.env.VITE_API_BASE': JSON.stringify(process.env.VITE_API_BASE || 'http://localhost:8080') } })
|
创建全局布局
在src目录创建layouts/CommonLayout.vue
引入布局模块并修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <template> <div class="common-layout"> <el-container> <EHeader/> <el-main class="main"> <router-view/> </el-main> <el-footer class="footer">微光电子书屋</el-footer> </el-container> </div> </template>
<script lang="ts" setup>
import EHeader from "@/components/EHeader.vue"; </script>
<style scoped> .footer { text-align: center; padding: 16px; background-color: #f5f7fa; } </style>
|
安装 Axios
npm install axios
通过Axios文档CV过来
创建请求配置文件/src/request.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| import axios from "axios";
alert(process.env.NODE_ENV);
const myAxios = axios.create({ baseURL: process.env.NODE_ENV === "development" ? "http://localhost:8080" : "https://wzcwzc10.github.io", timeout: 10000, withCredentials: true, });
myAxios.interceptors.request.use( function (config) { return config; }, function (error) { return Promise.reject(error); } );
myAxios.interceptors.response.use( function (response) { console.log(response);
const { data } = response; console.log(data); if (data.code === 40100) { if ( !response.request.responseURL.includes("user/current") && !window.location.pathname.includes("/user/login") ) { window.location.href = `/user/login?redirect=${window.location.href}`; } } return response; }, function (error) { if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { alert('请求超时,请稍后重试'); } else if (error.response?.status === 500) { alert('服务器开小差了~'); } return Promise.reject(error); } );
export default myAxios;
|
创建用户状态管理
前后端联动
遇到端口冲突:–port 3000
useLoginUserStore.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import { defineStore } from "pinia"; import { ref } from "vue"; import { getCurrentUser } from "@/api/user";
export const useLoginUserStore = defineStore("loginUser", () => { const loginUser = ref<any>({ username: "未登录", });
async function fetchLoginUser() { const res = await getCurrentUser(); if (res.data.code === 0 && res.data.data) { loginUser.value = res.data.data; } }
function setLoginUser(newLoginUser: any) { loginUser.value = newLoginUser; }
function clearLoginUser() { loginUser.value = { username: "未登录" }; }
async function updateLoginUser() { const res = await getCurrentUser(); if (res.data.code === 0 && res.data.data) { loginUser.value = res.data.data; } }
return { loginUser, fetchLoginUser, setLoginUser, clearLoginUser }; });
|
新建src/api/user.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| import myAxios from "@/request";
export const userRegister = async (params: any) => { return myAxios.request({ url: "/api/user/register", method: "POST", data: params, }); };
export const userLogin = async (params: any) => { return myAxios.request({ url: "/api/user/login", method: "POST", data: params, }); };
export const userLogout = async (params: any) => { return myAxios.request({ url: "/api/user/logout", method: "POST", data: params, }); };
export const updateUser = async (params: any) => { return myAxios.request({ url: "/api/user/update", method: "POST", data: params, }); };
export const getCurrentUser = async () => { return myAxios.request({ url: "/api/user/current", method: "GET", }); };
export const searchUsers = async (username: any) => { return myAxios.request({ url: "/api/user/search", method: "GET", params: { username, }, }); };
export const deleteUser = async (id: string) => { return myAxios.request({ url: "/api/user/delete", method: "POST", data: id, headers: { "Content-Type": "application/json", }, }); };
|
路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import { createRouter, createWebHistory } from 'vue-router' import HomeView from "@/views/HomeView.vue"; import UserLoginView from "@/views/UserLoginView.vue"; import UserRegisterView from "@/views/UserRegisterView.vue"; import AboutView from "@/views/AboutView.vue"; import MyBookView from "@/views/MyBookView.vue"; import UserView from "@/views/UserView.vue"; import BookDetailView from "@/views/BookDetailView.vue"; import BookReaderView from "@/views/BookReaderView.vue"; import UploadView from "@/views/UploadView.vue"; import HelpView from "@/views/HelpView.vue";
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView }, { path: '/about', name: 'about', component: AboutView }, { path: '/help', name: 'help', component: HelpView }, { path: "/user/login", name: "userLogin", component: UserLoginView }, { path: "/user/register", name: "userRegister", component: UserRegisterView }, { path: "/bookshelf", name: "bookshelf", component: MyBookView }, { path: '/user', name: 'user', component: UserView}, { path: '/book/:id', name: 'bookDetail', component: BookDetailView}, { path: '/book/reader:id', name: 'bookReader', component: BookReaderView }, { path: '/upload', name: 'upload', component: UploadView }, ], }) export default router
|
电子书阅读功能
集成 EPUB 阅读器(推荐 epub.js)
1 2 3 4 5
| npm install epubjs # 如果用 TypeScript,加类型(可选) npm install --save-dev @types/epubjs # PDF 阅读器(目前使用原生) npm install pdfjs-dist
|
页面
全局页面组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { getCurrentUser } from '@/api/user';
const currentUser = ref<{ id: number; userName: string; userAccount: string; avatarUrl: string; gender: number; userRole: number; } | null>(null);
const loading = ref<boolean>(true);
onMounted(async () => { try { const response = await getCurrentUser(); if (response.data.code === 0) { currentUser.value = response.data.data; } else { console.error('获取用户失败:', response.data.message); } } catch (error) { console.error('请求异常:', error); } finally { loading.value = false; } }); </script> <template> <div v-if="loading" style="margin-top: 20px"> 加载中... </div> <el-descriptions v-else title="微光名片" direction="vertical" border style="margin-top: 20px; max-width: 500px" > <el-descriptions-item :rowspan="2" :width="140" label="头像" align="center" > <el-image style="width: 100px; height: 100px; border-radius: 50%" :src="currentUser?.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" fit="cover" alt="用户头像" /> </el-descriptions-item> <el-descriptions-item label="账号">{{ currentUser?.userAccount || '未设置' }}</el-descriptions-item> <el-descriptions-item label="昵称">{{ currentUser?.userName || '未设置' }}</el-descriptions-item> <el-descriptions-item label="性别"> {{currentUser?.gender === 1 ? '男' : currentUser?.gender === 0 ? '女' : '未知' }} </el-descriptions-item> <el-descriptions-item label="级别"> <el-tag size="small"> {{currentUser?.userRole === 0 ? '普通用户' : currentUser?.userRole === 1 ? '管理员' : currentUser?.userRole === 2 ? 'VIP':'未知'}} </el-tag> </el-descriptions-item> <el-descriptions-item label="ID">{{ currentUser?.id || '无' }}</el-descriptions-item> </el-descriptions> </template>
<style scoped>
.el-descriptions { max-width: 500px; margin: 0 auto; } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
| <script setup lang="ts"> import {Search} from "@element-plus/icons-vue"; import {computed, onMounted, reactive, ref, toRefs} from "vue"; import {useRouter} from "vue-router"; import {userLogout} from "@/api/user.ts"; import {LoginOut} from "@/components/LoginOut.js"; import {useLoginUserStore} from "@/stores/userLoginUserStore.ts"; import {ElMessage} from "element-plus";
const router = useRouter() const activeIndex = ref('1') const input = ref('') const { logout } = LoginOut(); const avatarUrl = ref('')
const userStore = useLoginUserStore();
const isLogin = computed(() => { return userStore.loginUser?.username !== '未登录'; }); const handleSelect = async(key: string, keyPath: string[]) => { console.log(key, keyPath) switch (key) { case '0': router.push('/') break case '1-1': router.push('/user/login') break case '1-2': router.push('/user') break case '1-4': await logout(); break case '2': router.push('/bookshelf') break case '3': router.push('/help') break case '4': router.push('/about') break case '5': router.push('/upload') break } }
onMounted(async() => { await userStore.fetchLoginUser(); const user = userStore.loginUser; if ( user.username === '未登录') { ElMessage.warning('用户未登录'); return; } avatarUrl.value = user.avatarUrl || ''; });
const circleUrl = computed(() => { const user = userStore.loginUser; return user.avatarUrl || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'; });
</script>
<template> <el-header height="90px" class="header"> <el-menu :default-active="activeIndex" mode="horizontal" background-color="#ffffff" text-color="#606266" active-text-color="#409EFF" :ellipsis="false" @select="handleSelect" class="main-menu" > <el-menu-item index="0" class="logo-item"> <img src="@/assets/images/logo.png" alt="logo" class="logo-img" /> <span class="logo-text"> <el-text type="success" size="large">微光</el-text> <el-text type="primary" size="large">电子书屋</el-text> </span> </el-menu-item> <div class="menu-spacer"></div> <el-menu-item class="search-item"> <el-input size="large" v-model="input" placeholder="请输入书名" :suffix-icon="Search" class="search-input" /> </el-menu-item> <div class="menu-spacer"></div> <el-avatar :size="50" :src="circleUrl" v-if="isLogin" /> <el-sub-menu index="1" v-if="isLogin"> <template #title>个人</template> <el-menu-item index="1-2">个人信息</el-menu-item> <el-menu-item index="1-3">VIP</el-menu-item> <el-menu-item index="1-4">退出登录</el-menu-item> </el-sub-menu> <el-menu-item index="1-1" v-if="!isLogin">登录</el-menu-item> <el-menu-item index="2" v-if="isLogin">我的书架</el-menu-item> <el-menu-item index="3" v-if="isLogin">帮助</el-menu-item> <el-menu-item index="4">关于</el-menu-item> <el-menu-item index="5" v-if="isLogin">上传</el-menu-item> </el-menu> </el-header> </template>
<style scoped> .header { padding: 0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); }
.main-menu { border: none; display: flex; align-items: center; height: 100%; }
.logo-item { display: flex; align-items: center; gap: 8px; padding-left: 20px !important; }
.logo-img { width: 42px; height: auto; }
.logo-text { display: flex; align-items: center; gap: 4px; }
.search-item { padding: 0 20px !important; }
.search-input { width: 260px; }
.menu-spacer { flex: 1; } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
| <template> <el-scrollbar height="1200px" width="100%" @end-reached="handleLoadMore" :always="false" style="overflow-x: hidden" > <el-row :gutter="30" class="card-container"> <el-col v-for="colIndex in 3" :key="colIndex" :span="8" class="column" > <div v-for="book in columns[colIndex - 1]" :key="book.id" class="card-item" @click="goToDetail(book.id)" > <el-card shadow="hover" class="custom-card"> <div class="book-cover"> <img v-if="book.coverUrl" :src="book.coverUrl.startsWith('http') ? book.coverUrl : 'http://localhost:8080' + book.coverUrl" alt="封面" @error="setDefaultCover" /> <el-icon v-else size="40" color="#ccc"> <Document /> </el-icon> </div> <div class="book-info"> <div class="book-title">{{ book.title }}</div> <div class="book-author">作者:{{ book.author }}</div> </div> </el-card> </div> </el-col> </el-row>
<div v-if="loading && books.length > 0" class="loading-tip"> 加载中... </div>
<div v-if="!loading && books.length === 0" class="no-data"> 暂无书籍 </div> </el-scrollbar> </template>
<script lang="ts" setup> import { ref, computed, onMounted } from 'vue' import { Document } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import router from '@/router' import myAxios from "@/request.ts";
const books = ref<any[]>([]) const loading = ref(false) const hasMore = ref(true) const currentPage = ref(1) const pageSize = 12
const columns = computed(() => { const cols: any[][] = [[], [], []] books.value.forEach((book, index) => { cols[index % 3].push(book) }) return cols })
const fetchBooks = async (page: number, append = false) => { if (loading.value || !hasMore.value) return loading.value = true
try { const res = await myAxios.get('/api/book/list', { params: { current: page, pageSize: pageSize, } })
if (res.data.code !== 0) { throw new Error(res.data.message || '请求失败') }
const list = res.data.data || []
if (append) { books.value.push(...list) } else { books.value = list }
hasMore.value = list.length === pageSize currentPage.value = page } catch (err: any) { console.error('加载书籍失败:', err) hasMore.value = false } finally { loading.value = false } }
const handleLoadMore = (direction: string) => { if (direction === 'bottom') { fetchBooks(currentPage.value + 1, true) } }
const setDefaultCover = (e: Event) => { const img = e.target as HTMLImageElement img.src = '/default-cover.png' }
const goToDetail = (bookId: number) => { router.push(`/book/${bookId}`) }
onMounted(() => { fetchBooks(1) }) </script>
<style scoped> .card-container { padding: 20px; }
.column { display: flex; flex-direction: column; gap: 16px; }
.custom-card { width: 80%; min-height: 280px; border-radius: 12px; cursor: pointer; transition: transform 0.2s; }
.custom-card:hover { transform: translateY(-4px); }
.book-cover { width: 100%; height: 180px; display: flex; align-items: center; justify-content: center; background-color: #f5f7fa; border-radius: 8px; overflow: hidden; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; }
.book-info { margin-top: 12px; text-align: center; }
.book-title { font-weight: bold; font-size: 16px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.book-author { font-size: 14px; color: #666; margin-top: 6px; }
.loading-tip, .no-data { text-align: center; padding: 20px; color: var(--el-color-info); } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| <template> <el-upload class="avatar-uploader" action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15" :show-file-list="false" :on-success="handleAvatarSuccess" :before-upload="beforeAvatarUpload" > <img v-if="imageUrl" :src="imageUrl" class="avatar" /> <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon> </el-upload> </template>
<script lang="ts" setup> import { ref } from 'vue' import { ElMessage } from 'element-plus' import { Plus } from '@element-plus/icons-vue'
import type { UploadProps } from 'element-plus'
const imageUrl = ref('')
const handleAvatarSuccess: UploadProps['onSuccess'] = ( response, uploadFile ) => { imageUrl.value = URL.createObjectURL(uploadFile.raw!) }
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => { if (rawFile.type !== 'image/jpeg') { ElMessage.error('Avatar picture must be JPG format!') return false } else if (rawFile.size / 1024 / 1024 > 2) { ElMessage.error('Avatar picture size can not exceed 2MB!') return false } return true } </script>
<style scoped> .avatar-uploader .avatar { width: 178px; height: 178px; display: block; } </style>
<style> .avatar-uploader .el-upload { border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; transition: var(--el-transition-duration-fast); }
.avatar-uploader .el-upload:hover { border-color: var(--el-color-primary); }
.el-icon.avatar-uploader-icon { font-size: 28px; color: #8c939d; width: 178px; height: 178px; text-align: center; } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| export function LoginOut() { const router = useRouter(); const userStore = useLoginUserStore();
const logout = async () => { try { await userLogout({});
userStore.clearLoginUser();
ElMessage.success('已退出登录'); router.push('/user/login'); } catch (error: any) { console.error('登出失败:', error); const msg = error?.response?.data?.message || '登出失败,请重试'; ElMessage.error(msg); } };
return { logout, }; }
|
页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <template> <div class="home"> <el-container> <el-header> <ECarousel/> </el-header> <el-main> <ECord/> </el-main> </el-container> </div> </template>
<style scoped> .home { display: flex; justify-content: center; align-items: stretch; min-height: 75vh; background: linear-gradient(0deg, rgba(144, 255, 0, 0.5), rgb(255, 255, 255)); } .el-header { text-align: center; background: rgb(255, 255, 255); } .el-main { padding: 150px; } </style> <script setup lang="ts"> import ECarousel from "@/components/ECarousel.vue"; import ECord from "@/components/ECord.vue"; </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
| <template> <div class="profile-layout"> <div class="settings-panel"> <el-card class="settings-card"> <template #header> <div class="card-header"> <span> <div style="display: flex; align-items: center; gap: 8px;"> 当前账号 <el-avatar :src="avatarUrl || undefined" fallback-icon="UserFilled" /> </div> </span> </div> </template>
<el-collapse v-model="activeNames" @change="handleChange"> <el-collapse-item title="修改信息" name="1"> <div class="form-item"> <label>头像</label> <div style="display: flex; gap: 8px; align-items: center;"> <el-input v-model="avatarUrl" style="width: 240px" placeholder="请输入头像网址" /> <el-button type="success" :icon="Check" circle @click="saveField('avatarUrl')" /> </div> </div>
<div class="form-item"> <label>昵称</label> <div style="display: flex; gap: 8px; align-items: center;"> <el-input v-model="userName" style="width: 240px" placeholder="请输入昵称" /> <el-button type="success" :icon="Check" circle @click="saveField('userName')" /> </div> </div>
<div class="form-item"> <label>性别</label> <div style="display: flex; gap: 8px; align-items: center;"> <el-select v-model="gender" placeholder="请选择" style="width: 240px"> <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" /> </el-select> <el-button type="success" :icon="Check" circle @click="saveField('gender')" :loading="loading.gender"/> </div> </div> </el-collapse-item>
<el-collapse-item title="获取VIP" name="2"> <div class="payment-method"> <p>请选择支付方式:</p> <el-button type="primary" plain style="margin-right: 12px;">微信支付</el-button> <el-button type="primary" plain>支付宝</el-button> </div> </el-collapse-item> </el-collapse>
<template #footer>微光电子书屋</template> </el-card> </div>
<div class="business-card-wrapper"> <BusinessCard /> </div> </div> </template>
<script lang="ts" setup> import {onMounted, ref, watch} from 'vue' import { Check } from '@element-plus/icons-vue' import BusinessCard from "@/components/BusinessCard.vue" import {useLoginUserStore} from "@/stores/userLoginUserStore.ts"; import {ElMessage} from "element-plus"; import {updateUser} from "@/api/user.ts";
const userStore = useLoginUserStore(); const activeNames = ref(['1']) const loading = ref<{ [key: string]: boolean }>({}); const handleChange = (val: string | string[]) => { console.log('当前展开项:', val) }
const options = [ { value: 1, label: '男' }, { value: 0, label: '女' }, { value: 2, label: '保密' }, ]
onMounted(async() => { await userStore.fetchLoginUser(); const user = userStore.loginUser; if ( user.username === '未登录') { ElMessage.warning('用户未登录'); return; } avatarUrl.value = user.avatarUrl || ''; userName.value = user.userName || ''; gender.value = user.gender || ''; });
const avatarUrl = ref('') const userName = ref('') const gender = ref('')
const saveField = async (field: 'avatarUrl' | 'userName' | 'gender') => { if (loading.value[field]) return;
let data: any = {}; if (field === 'avatarUrl') { if (!avatarUrl.value.trim()) { ElMessage.warning('请输入头像链接'); return; } data.avatarUrl = avatarUrl.value; } else if (field === 'userName') { if (!userName.value.trim()) { ElMessage.warning('昵称不能为空'); return; } data.userName = userName.value; } else if (field === 'gender') { data.gender = gender.value; }
try { loading.value[field] = true; const res = await updateUser(data);
if (res.data.code === 0) { ElMessage.success('保存成功');
userStore.setLoginUser({ ...userStore.loginUser, ...data, }); } else { ElMessage.error(res.data.message || '保存失败'); } } catch (error: any) { console.error('更新失败:', error); ElMessage.error('网络错误,请重试'); } finally { loading.value[field] = false; } };
</script>
<style scoped> .profile-layout { display: flex; height: 75vh; width: 90vw; overflow: hidden; }
.settings-panel { flex: 1; padding: 24px; display: flex; justify-content: center; align-items: flex-start; overflow-y: auto; }
.settings-card { max-width: 480px; width: 100%; }
.business-card-wrapper { flex: 1 1 200px; background-color: #ffffff; border-left: 1px solid rgb(255, 255, 255); overflow-y: hidden; }
.form-item { margin-bottom: 16px; } .form-item label { display: block; margin-bottom: 6px; font-weight: bold; } .payment-method { padding: 8px 0; } </style>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
| <template> <div class="form-container"> <el-form ref="loginFormRef" style="max-width: 400px; width: 100%; padding: 24px" :model="loginForm" status-icon :rules="loginRules" label-width="auto" class="demo-loginForm" > <h2 style="text-align: center; margin-bottom: 24px">用户登录</h2> <el-form-item label="账号" prop="user"> <el-input v-model.number="loginForm.userAccount" placeholder="请输入账号" /> </el-form-item> <el-form-item label="密码" prop="pass"> <el-input v-model="loginForm.userPassword" type="password" autocomplete="off" placeholder="请输入密码" /> </el-form-item> <el-form-item> <el-button type="primary" @click="submitLogin(loginFormRef)" style="width: 100%"> 登录 </el-button> <el-button link type="primary" style="width: 100%; margin-top: 12px" @click="goToRegister"> 没有账号?去注册 </el-button> </el-form-item> </el-form> </div> </template>
<script lang="ts" setup> import { reactive, ref } from 'vue' import type { FormInstance, FormRules } from 'element-plus' import { useRouter } from 'vue-router' import {userLogin} from "@/api/user.ts"; import {useLoginUserStore} from "@/stores/userLoginUserStore.ts"; import {ElMessage} from "element-plus";
const loginFormRef = ref<FormInstance>()
const loginForm = reactive({ userAccount: '', userPassword: '' })
const validateUser = (rule: any, value: any, callback: any) => { if (!value) { callback(new Error('请输入账号')) } else if (!/^[a-zA-Z0-9]+$/.test(value)) { callback(new Error('账号只能包含字母和数字')); } else { callback() } }
const validatePass = (rule: any, value: any, callback: any) => { if (!value) { callback(new Error('请输入密码')) } else { callback() } }
const loginRules = reactive<FormRules<typeof loginForm>>({ userAccount: [{ validator: validateUser, trigger: 'blur' }], userPassword: [{ validator: validatePass, trigger: 'blur' }] })
const submitLogin = async (formEl: FormInstance | undefined) => { if (!formEl) return;
const valid = await formEl.validate(); // 使用 Promise 风格 if (valid) { try { const res = await userLogin({ userAccount: loginForm.userAccount, // 注意:后端字段名是否为 username? userPassword: loginForm.userPassword, });
if (res.data?.code === 0) { // 登录成功:更新全局用户状态 const store = useLoginUserStore(); store.setLoginUser(res.data.data); // 或根据实际返回结构调整
ElMessage.success('登录成功!');
// 跳转到首页(根据你的路由配置) router.push('/'); // 或 '/dashboard' 等 } else { ElMessage.error(res.data?.message || '登录失败,请检查账号或密码'); } } catch (error) { ElMessage.error('网络错误或服务器异常'); console.error('登录请求失败:', error); } } };
// 跳转到注册页(使用 Vue Router) const router = useRouter() const goToRegister = () => { router.push('/user/register') // 根据路由配置调整 } </script>
<style scoped> .form-container { display: flex; justify-content: center; align-items: stretch; min-height: 75vh; background-color: rgb(255, 255, 255); } </style>
|
后端
初始化
Spring Boot 3.5.8
选择功能:
Lombok + Spring Web + Spring Boot DevTools + Spring Configuration Processor + MySQL Driver + MyBatis Framework
引入 MyBatis-Plus
1 2 3 4 5
| <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.14</version> </dependency>
|
引入 commons-lang3 依赖
1 2 3 4 5 6
| <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.18.0</version> </dependency>
|
配置application.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| spring: application: name: wg-Ebook
datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/wgebook username: root password: 123456
servlet: multipart: enabled: true max-file-size: 100MB max-request-size: 100MB
server: port: 8080 servlet: session: timeout: 1800s
mybatis-plus: configuration: map-underscore-to-camel-case: false
|
接口文档
1 2 3 4 5 6
| <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.8.14</version> </dependency>
|
http://localhost:8080/swagger-ui.html
主类
common 模块
创建 common 模块,用于存放通用类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
| @RestController @RequestMapping("/api/progress") @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") public class ReadingProgressController {
@Resource private ReadingProgressService readingProgressService;
@PostMapping("/save") public BaseResponse<Boolean> saveProgress(@RequestBody ReadingProgress progress, HttpServletRequest request) { if (progress == null || progress.getBookId() == null || progress.getBookId() <= 0 || progress.getCurrentLocation() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request); progress.setUserId(currentUserId); progress.setLastReadTime(new Date()); progress.setIsDelete(CommonConstant.NOT_DELETED);
ReadingProgress existing = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getBookId, progress.getBookId()) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .one();
boolean result; if (existing != null) { existing.setCurrentLocation(progress.getCurrentLocation()); existing.setLastReadTime(progress.getLastReadTime()); result = readingProgressService.updateById(existing); } else { result = readingProgressService.save(progress); }
return ResultUtils.success(result); }
@GetMapping("/get") public BaseResponse<ReadingProgress> getProgress(Long bookId, HttpServletRequest request) { if (bookId == null || bookId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request);
ReadingProgress progress = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getBookId, bookId) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .one();
return ResultUtils.success(progress); }
@GetMapping("/list") public BaseResponse<List<ReadingProgress>> listProgress(HttpServletRequest request) { Long currentUserId = getCurrentUserId(request);
List<ReadingProgress> list = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .list();
return ResultUtils.success(list); }
private Long getCurrentUserId(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); if (userObj == null) { throw new BusinessException(ErrorCode.NOT_LOGIN); } try { fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj; return user.getId(); } catch (Exception e) { throw new BusinessException(ErrorCode.NOT_LOGIN); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
public enum ErrorCode {
SUCCESS(0, "ok", ""), PARAMS_ERROR(40000, "请求参数错误", ""), NULL_ERROR(40001, "请求数据为空", ""), NOT_LOGIN(40100, "未登录", ""), NO_AUTH(40101, "无权限", ""), SYSTEM_ERROR(50000, "系统内部异常", "");
private final int code;
private final String message;
private final String description;
ErrorCode(int code, String message, String description) { this.code = code; this.message = message; this.description = description; }
public int getCode() { return code; }
public String getMessage() { return message; }
public String getDescription() { return description; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
|
public class ResultUtils {
public static <T> BaseResponse<T> success(T data) { return new BaseResponse<>(0, data, "ok"); }
public static BaseResponse error(ErrorCode errorCode) { return new BaseResponse<>(errorCode); }
public static BaseResponse error(int code, String message, String description) { return new BaseResponse(code, null, message, description); }
public static BaseResponse error(ErrorCode errorCode, String message, String description) { return new BaseResponse(errorCode.getCode(), null, message, description); }
public static BaseResponse error(ErrorCode errorCode, String description) { return new BaseResponse(errorCode.getCode(), errorCode.getMessage(), description); } }
|
contant 模块
创建 contant 模块,用于存放常量类。
1 2 3 4 5 6 7 8
|
public interface CommonConstant { int NOT_DELETED = 0; int DELETED = 1; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public interface UserConstant {
String USER_LOGIN_STATE = "userLoginState";
int DEFAULT_ROLE = 0; int ADMIN_ROLE = 1; int VIP_ROLE = 2; }
|
1 2 3 4 5 6 7 8
|
public interface BookConstant { int BOOK_STATUS_NORMAL = 1; int BOOK_STATUS_DELETED = 0; }
|
Controller 类
创建 Controller 类,用于处理请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299
| @RestController @RequestMapping("/api/book") @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") public class BooksController {
@Resource private BooksService booksService;
@PostMapping("/add") public BaseResponse<Long> addBook(@RequestBody Books book, HttpServletRequest request) { if (book == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH, "仅管理员可添加书籍"); }
if (StringUtils.isAnyBlank(book.getTitle(), book.getAuthor(), book.getFilePath())) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "书名、作者、文件路径不能为空"); }
if (book.getIsbn() != null && book.getIsbn().trim().isEmpty()) { book.setIsbn(null); } if (book.getCoverUrl() != null && book.getCoverUrl().trim().isEmpty()) { book.setCoverUrl(null); } if (book.getDescription() != null && book.getDescription().trim().isEmpty()) { book.setDescription(null); }
boolean saved = booksService.save(book); if (!saved) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "添加失败"); } return ResultUtils.success(book.getId()); }
@PostMapping("/delete") public BaseResponse<Boolean> deleteBook(@RequestBody long id, HttpServletRequest request) { if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH); } if (id <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Books book = booksService.getById(id); if (book == null) { throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不存在"); }
book.setBookStatus(BookConstant.BOOK_STATUS_DELETED); boolean updated = booksService.updateById(book); return ResultUtils.success(updated); }
@PostMapping("/update") public BaseResponse<Boolean> updateBook(@RequestBody Books book, HttpServletRequest request) { if (book == null || book.getId() == null || book.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH); }
Books existing = booksService.getById(book.getId()); if (existing == null) { throw new BusinessException(ErrorCode.NULL_ERROR); }
boolean updated = booksService.updateById(book); return ResultUtils.success(updated); }
@GetMapping("/get") public BaseResponse<Books> getBook(long id) { if (id <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } Books book = booksService.getById(id); if (book == null || book.getBookStatus() != BookConstant.BOOK_STATUS_NORMAL) { throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不存在或已下架"); } return ResultUtils.success(book); }
@GetMapping("/list") public BaseResponse<List<Books>> listBooks( String keyword, @RequestParam(defaultValue = "1") int current, @RequestParam(defaultValue = "10") int pageSize) {
LambdaQueryWrapper<Books> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Books::getBookStatus, BookConstant.BOOK_STATUS_NORMAL);
if (StringUtils.isNotBlank(keyword)) { queryWrapper.and(wrapper -> wrapper.like(Books::getTitle, keyword) .or() .like(Books::getAuthor, keyword) ); }
List<Books> list = booksService.list(queryWrapper); return ResultUtils.success(list); }
@GetMapping("/admin/list") public BaseResponse<List<Books>> adminListBooks(String keyword, HttpServletRequest request) { if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH); }
QueryWrapper<Books> queryWrapper = new QueryWrapper<>(); if (StringUtils.isNotBlank(keyword)) { queryWrapper.and(wrapper -> wrapper.like("title", keyword) .or() .like("author", keyword) ); }
List<Books> list = booksService.list(queryWrapper); return ResultUtils.success(list); }
private boolean isAdmin(HttpServletRequest request) { Object userObj = request.getSession().getAttribute("userLoginState"); if (userObj == null) return false; try { fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj; return user != null && user.getUserRole() == 1; } catch (Exception e) { return false; } } @GetMapping("/{id}") public BaseResponse<Books> getBookById(@PathVariable Long id) { if (id == null || id <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } Books book = booksService.getById(id); if (book == null || !Integer.valueOf(BookConstant.BOOK_STATUS_NORMAL).equals(book.getBookStatus())) { throw new BusinessException(ErrorCode.NULL_ERROR); } return ResultUtils.success(book); }
@PostMapping("/batch") public BaseResponse<List<Books>> getBooksByIds(@RequestBody Map<String, List<Long>> request) { List<Long> ids = request.get("ids"); if (ids == null || ids.isEmpty()) { return ResultUtils.success(List.of()); }
List<Books> books = booksService.listByIds(ids); List<Books> validBooks = books.stream() .filter(book -> book.getBookStatus() != null && book.getBookStatus() == BookConstant.BOOK_STATUS_NORMAL) .collect(Collectors.toList());
return ResultUtils.success(validBooks); }
@GetMapping("/read/{id}") public void readBook(@PathVariable Long id, HttpServletRequest request, HttpServletResponse response) throws IOException { if (id == null || id <= 0) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "无效的书籍ID"); return; }
Books book = booksService.getById(id); if (book == null || book.getBookStatus() == null || book.getBookStatus() != BookConstant.BOOK_STATUS_NORMAL) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "书籍不存在或已下架"); return; }
String filePath = book.getFilePath(); if (StringUtils.isBlank(filePath)) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "书籍文件路径缺失"); return; }
File file = new File("src/main/resources/static", filePath); if (!file.exists() || !file.isFile()) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "书籍文件不存在"); return; }
response.setContentType(getContentType(book.getFormat())); response.setContentLengthLong(file.length()); response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(book.getTitle(), "UTF-8") + "." + book.getFormat() + "\"");
try (FileInputStream fis = new FileInputStream(file); OutputStream os = response.getOutputStream()) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = fis.read(buffer)) != -1) { os.write(buffer, 0, bytesRead); } os.flush(); } }
private String getContentType(String format) { if ("pdf".equalsIgnoreCase(format)) { return "application/pdf"; } else if ("epub".equalsIgnoreCase(format)) { return "application/epub+zip"; } return "application/octet-stream"; }
@PostMapping("/upload") public BaseResponse<String> uploadFile(@RequestParam("file") MultipartFile file, HttpServletRequest request) { if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH, "仅管理员可上传文件"); }
if (file.isEmpty()) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件不能为空"); }
String originalFilename = file.getOriginalFilename(); if (originalFilename == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件名无效"); }
String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); if (!"epub".equals(ext) && !"pdf".equals(ext)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "仅支持 EPUB 或 PDF 格式"); }
String uniqueName = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + "." + ext;
String uploadDir = "src/main/resources/static/ebooks/"; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); }
try { Path filePath = Paths.get(uploadDir, uniqueName); Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件上传失败"); }
return ResultUtils.success("ebooks/" + uniqueName); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
| @RestController @RequestMapping("/api/progress") @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") public class ReadingProgressController {
@Resource private ReadingProgressService readingProgressService;
@PostMapping("/save") public BaseResponse<Boolean> saveProgress(@RequestBody ReadingProgress progress, HttpServletRequest request) { if (progress == null || progress.getBookId() == null || progress.getBookId() <= 0 || progress.getCurrentLocation() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request); progress.setUserId(currentUserId); progress.setLastReadTime(new Date()); progress.setIsDelete(CommonConstant.NOT_DELETED);
ReadingProgress existing = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getBookId, progress.getBookId()) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .one();
boolean result; if (existing != null) { existing.setCurrentLocation(progress.getCurrentLocation()); existing.setLastReadTime(progress.getLastReadTime()); result = readingProgressService.updateById(existing); } else { result = readingProgressService.save(progress); }
return ResultUtils.success(result); }
@GetMapping("/get") public BaseResponse<ReadingProgress> getProgress(Long bookId, HttpServletRequest request) { if (bookId == null || bookId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request);
ReadingProgress progress = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getBookId, bookId) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .one();
return ResultUtils.success(progress); }
@GetMapping("/list") public BaseResponse<List<ReadingProgress>> listProgress(HttpServletRequest request) { Long currentUserId = getCurrentUserId(request);
List<ReadingProgress> list = readingProgressService.lambdaQuery() .eq(ReadingProgress::getUserId, currentUserId) .eq(ReadingProgress::getIsDelete, CommonConstant.NOT_DELETED) .list();
return ResultUtils.success(list); }
private Long getCurrentUserId(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); if (userObj == null) { throw new BusinessException(ErrorCode.NOT_LOGIN); } try { fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj; return user.getId(); } catch (Exception e) { throw new BusinessException(ErrorCode.NOT_LOGIN); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| @RestController @RequestMapping("/api/bookshelf") @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") public class UserBookshelfController {
@Resource private UserBookshelfService userBookshelfService;
@PostMapping("/add") public BaseResponse<Boolean> addToBookshelf( @RequestBody AddToBookshelfRequest addRequest, HttpServletRequest request) {
Long bookId = addRequest.getBookId(); if (bookId == null || bookId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request);
UserBookshelf existing = userBookshelfService.getOne( Wrappers.<UserBookshelf>lambdaQuery() .eq(UserBookshelf::getUserId, currentUserId) .eq(UserBookshelf::getBookId, bookId), false );
if (existing != null) { if (existing.getIsDelete() == CommonConstant.DELETED) { existing.setIsDelete(CommonConstant.NOT_DELETED); existing.setCreateTime(new Date()); boolean updated = userBookshelfService.updateById(existing); return ResultUtils.success(updated); } else { return ResultUtils.success(true); } } else { UserBookshelf shelfItem = new UserBookshelf(); shelfItem.setUserId(currentUserId); shelfItem.setBookId(bookId); shelfItem.setCreateTime(new Date()); shelfItem.setIsDelete(CommonConstant.NOT_DELETED); boolean saved = userBookshelfService.save(shelfItem); return ResultUtils.success(saved); } }
@PostMapping("/remove") public BaseResponse<Boolean> removeFromBookshelf( @RequestBody BookIdRequest bookRequest, HttpServletRequest request) {
Long bookId = bookRequest.getBookId(); if (bookId == null || bookId <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
Long currentUserId = getCurrentUserId(request);
UserBookshelf existing = userBookshelfService.lambdaQuery() .eq(UserBookshelf::getUserId, currentUserId) .eq(UserBookshelf::getBookId, bookId) .eq(UserBookshelf::getIsDelete, CommonConstant.NOT_DELETED) .one();
if (existing == null) { throw new BusinessException(ErrorCode.NULL_ERROR, "书籍不在书架中"); }
return ResultUtils.success(userBookshelfService.removeById(existing.getId())); }
@GetMapping("/list") public BaseResponse<List<UserBookshelf>> getBookshelf(HttpServletRequest request) { Long currentUserId = getCurrentUserId(request);
List<UserBookshelf> list = userBookshelfService.lambdaQuery() .eq(UserBookshelf::getUserId, currentUserId) .eq(UserBookshelf::getIsDelete, CommonConstant.NOT_DELETED) .list();
return ResultUtils.success(list); }
private Long getCurrentUserId(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); if (userObj == null) { throw new BusinessException(ErrorCode.NOT_LOGIN); } try { fun.wgfun.ebook.model.User user = (fun.wgfun.ebook.model.User) userObj; return user.getId(); } catch (Exception e) { throw new BusinessException(ErrorCode.NOT_LOGIN); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
| @RestController @RequestMapping("/api/user") @CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true") public class UserController {
@Resource private UserService userService;
@PostMapping("/register") public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) { if (userRegisterRequest == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } String userAccount = userRegisterRequest.getUserAccount(); String userPassword = userRegisterRequest.getUserPassword(); String checkPassword = userRegisterRequest.getCheckPassword(); if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) { return ResultUtils.error(ErrorCode.PARAMS_ERROR); } long result = userService.userRegister(userAccount, userPassword, checkPassword); return ResultUtils.success(result); }
@PostMapping("/login") public BaseResponse<User> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) { if (userLoginRequest == null) { return ResultUtils.error(ErrorCode.PARAMS_ERROR); } String userAccount = userLoginRequest.getUserAccount(); String userPassword = userLoginRequest.getUserPassword(); if (StringUtils.isAnyBlank(userAccount, userPassword)) { return ResultUtils.error(ErrorCode.PARAMS_ERROR); } User user = userService.userLogin(userAccount, userPassword, request); return ResultUtils.success(user); }
@PostMapping("/logout") public BaseResponse<Integer> userLogout(HttpServletRequest request) { if (request == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } int result = userService.userLogout(request); return ResultUtils.success(result); }
@GetMapping("/current") public BaseResponse<User> getCurrentUser(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); User currentUser = (User) userObj; if (currentUser == null) { throw new BusinessException(ErrorCode.NOT_LOGIN); } long userId = currentUser.getId(); User user = userService.getById(userId); User safetyUser = userService.getSafetyUser(user); return ResultUtils.success(safetyUser); }
@GetMapping("/search") public BaseResponse<List<User>> searchUsers(String username, HttpServletRequest request) { if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH, "缺少管理员权限"); } QueryWrapper<User> queryWrapper = new QueryWrapper<>(); if (StringUtils.isNotBlank(username)) { queryWrapper.like("username", username); } List<User> userList = userService.list(queryWrapper); List<User> list = userList.stream().map(user -> userService.getSafetyUser(user)).collect(Collectors.toList()); return ResultUtils.success(list); }
@PostMapping("/delete") public BaseResponse<Boolean> deleteUser(@RequestBody long id, HttpServletRequest request) { if (!isAdmin(request)) { throw new BusinessException(ErrorCode.NO_AUTH); } if (id <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } boolean b = userService.removeById(id); return ResultUtils.success(b); }
private boolean isAdmin(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); User user = (User) userObj; return user != null && user.getUserRole() == ADMIN_ROLE; }
private boolean isVIP(HttpServletRequest request) { Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); User user = (User) userObj; return user != null && user.getUserRole() == VIP_ROLE; }
@PostMapping("/update") public BaseResponse<Integer> updateUser(@RequestBody User user, HttpServletRequest request) { if (user == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户信息不能为空"); }
HttpSession session = request.getSession(false); if (session == null) { throw new BusinessException(ErrorCode.NOT_LOGIN, "未登录"); }
Object currentUserObj = session.getAttribute(USER_LOGIN_STATE); if (currentUserObj == null) { throw new BusinessException(ErrorCode.NOT_LOGIN, "请先登录"); }
User currentUser = (User) currentUserObj; Long currentUserId = currentUser.getId(); if (currentUserId == null) { throw new BusinessException(ErrorCode.NOT_LOGIN, "用户状态异常"); }
if (user.getId() != null && !user.getId().equals(currentUserId)) { throw new BusinessException(ErrorCode.NO_AUTH, "无权限修改他人信息"); }
user.setId(currentUserId);
boolean result = userService.updateUser(user); if (!result) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "更新失败"); }
return ResultUtils.success(1); } }
|
Service 类
创建 Service 类,用于处理业务逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| public interface UserService extends IService<User> {
long userRegister(String userAccount, String userPassword, String checkPassword);
User userLogin(String userAccount, String userPassword, HttpServletRequest request);
User getSafetyUser(User originUser);
int userLogout(HttpServletRequest request);
boolean updateUser(User safetyUser); }
|
相应实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157
| @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{ @Resource private UserMapper userMapper;
private static final String SALT = "wgzc";
@Override public long userRegister(String userAccount, String userPassword, String checkPassword) { if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空"); } if (userAccount.length() < 2) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短"); } if (userPassword.length() < 5 || checkPassword.length() < 5) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短"); }
String validateRegExp = "[A-Za-z0-9_\\\\-\\\\u4e00-\\\\u9fa5]+"; Matcher matcher = Pattern.compile(validateRegExp).matcher(userAccount); if (!matcher.find()) { return -9; }
if (!userPassword.equals(checkPassword)) { return -2; }
QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userAccount", userAccount); Long count = userMapper.selectCount(queryWrapper); if (count > 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号重复"); } String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
User user = new User(); user.setUserAccount(userAccount); user.setUserPassword(encryptPassword); boolean saveResult = this.save(user); if (!saveResult) { return -1; } return user.getId(); }
@Override public User userLogin(String userAccount, String userPassword, HttpServletRequest request) { if (StringUtils.isAnyBlank(userAccount,userPassword)) { return null; } if(userAccount.length() < 2){ return null; } if(userPassword.length() < 5){ return null; }
String validPattern = "[^A-Za-z0-9_\\\\\\\\-\\\\\\\\u4e00-\\\\\\\\u9fa5]"; Matcher matcher = Pattern.compile(validPattern).matcher(userAccount); if (matcher.find()) { return null; }
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes()); QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userAccount",userAccount); queryWrapper.eq("userPassword",encryptPassword); User user = userMapper.selectOne(queryWrapper); if (user == null) { log.error("user login failed,userAccount cannot match userPassword"); return null; } User safetyUser = getSafetyUser(user);
request.getSession().setAttribute(USER_LOGIN_STATE,safetyUser);
return safetyUser; }
@Override public User getSafetyUser(User originUser) { if (originUser == null) { return null; } User safetyUser = new User(); safetyUser.setId(originUser.getId()); safetyUser.setUserName(originUser.getUserName()); safetyUser.setUserAccount(originUser.getUserAccount()); safetyUser.setGender(originUser.getGender()); safetyUser.setAvatarUrl(originUser.getAvatarUrl()); safetyUser.setUserStatus(originUser.getUserStatus()); safetyUser.setCreateTime(originUser.getCreateTime()); safetyUser.setUserRole(originUser.getUserRole()); return safetyUser; }
@Override public int userLogout(HttpServletRequest request) { request.getSession().removeAttribute(USER_LOGIN_STATE); return 1; }
@Override @Transactional(rollbackFor = Exception.class) public boolean updateUser(User user) { if (user == null || user.getId() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); }
User updateData = new User(); updateData.setId(user.getId()); updateData.setAvatarUrl(user.getAvatarUrl()); updateData.setUserName(user.getUserName()); updateData.setGender(user.getGender());
boolean updated = this.updateById(updateData);
return updated; } }
|
1 2 3 4 5
| public interface BooksService extends IService<Books> {}
@Service public class BooksServiceImpl extends ServiceImpl<BooksMapper, Books> implements BooksService{}
|
1 2 3 4 5
| public interface UserBookshelfService extends IService<UserBookshelf> {}
@Service public class UserBookshelfServiceImpl extends ServiceImpl<UserBookshelfMapper, UserBookshelf> implements UserBookshelfService {}
|
Mapper 类
创建 Mapper 类,用于操作数据库。
1 2 3 4
| public interface BooksMapper extends BaseMapper<Books> {} public interface ReadingProgressMapper extends BaseMapper<ReadingProgress> {} public interface UserBookshelfMapper extends BaseMapper<UserBookshelf> {} public interface UserMapper extends BaseMapper<User> {}
|
Exception 类
创建 Exception 类,用于处理异常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
public class BusinessException extends RuntimeException {
private final int code;
private final String description;
public BusinessException(String message, int code, String description) { super(message); this.code = code; this.description = description; }
public BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.code = errorCode.getCode(); this.description = errorCode.getDescription(); }
public BusinessException(ErrorCode errorCode, String description) { super(errorCode.getMessage()); this.code = errorCode.getCode(); this.description = description; }
public int getCode() { return code; }
public String getDescription() { return description; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
@RestControllerAdvice @Slf4j public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class) public BaseResponse<?> businessExceptionHandler(BusinessException e) { log.error("businessException: " + e.getMessage(), e); return ResultUtils.error(e.getCode(), e.getMessage(), e.getDescription()); }
@ExceptionHandler(RuntimeException.class) public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) { log.error("runtimeException", e); return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage(), ""); } }
|
Model 类
创建 Model 类,用于封装数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
|
@TableName(value ="user") @Data public class User {
@TableId(type = IdType.AUTO) private Long id;
private String userName;
private String userAccount;
private String avatarUrl;
private Integer gender;
private String userPassword;
private Integer userStatus;
private Date createTime;
private Date updateTime;
@TableLogic private Integer isDelete;
private Integer userRole; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
|
@TableName(value ="books") @Data public class Books {
@TableId(type = IdType.AUTO) private Long id;
private String title;
private String author;
private String isbn;
private String coverUrl;
private String description;
private String filePath;
private Long fileSize;
private String format;
@TableField(value = "bookStatus") private Integer bookStatus;
private Date createTime;
private Date updateTime; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
@TableName(value ="user_bookshelf") @Data public class UserBookshelf {
@TableId(type = IdType.AUTO) private Long id;
private Long userId;
private Long bookId;
private Date createTime;
private Integer isDelete; }
|
请求体
1 2 3 4 5 6 7 8 9 10 11 12 13
|
@Data public class UserRegisterRequest implements Serializable { @Serial private static final long serialVersionUID = 3191241716373120793L; private String userAccount; private String userPassword; private String checkPassword; }
|
1 2 3 4 5 6 7 8 9 10 11 12
|
@Data public class UserLoginRequest implements Serializable { @Serial private static final long serialVersionUID = 3191241716373120793L; private String userAccount; private String userPassword; }
|
1 2 3 4
| @Data public class BookIdRequest { private Long bookId; }
|
1 2 3 4
| @Data public class AddToBookshelfRequest { private Long bookId; }
|
1 2 3 4 5 6
| @Data public class BookUpload { private Long bookId; private String title; private String fileUrl; }
|