1. 前端书籍管理概述

前端书籍目录列表分章节管理是现代Web应用的重要组成部分,通过Vue组件化开发可以实现高效、可维护的用户界面。本文将详细介绍基于Vue的书籍目录列表分章节管理实现,包括组件设计、状态管理、路由配置、API集成的完整解决方案。

1.1 核心功能

  1. 书籍列表展示: 展示所有书籍的基本信息
  2. 章节目录管理: 按章节组织书籍内容
  3. 搜索和筛选: 支持书籍和章节的搜索功能
  4. 分页加载: 实现大数据量的分页展示
  5. 响应式设计: 适配不同设备屏幕

1.2 技术架构

1
2
3
用户界面 → Vue组件 → 状态管理 → API调用 → 后端服务
↓ ↓ ↓ ↓
组件化 → 数据绑定 → 路由管理 → 数据获取

2. 项目结构

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
src/
├── components/ # 组件目录
│ ├── BookList.vue # 书籍列表组件
│ ├── BookCard.vue # 书籍卡片组件
│ ├── ChapterList.vue # 章节列表组件
│ ├── ChapterItem.vue # 章节项组件
│ └── SearchBar.vue # 搜索栏组件
├── views/ # 页面视图
│ ├── BookDetail.vue # 书籍详情页
│ ├── ChapterDetail.vue # 章节详情页
│ └── Home.vue # 首页
├── store/ # 状态管理
│ ├── index.js # Store入口
│ ├── modules/ # 模块化状态
│ │ ├── book.js # 书籍状态
│ │ └── chapter.js # 章节状态
├── api/ # API接口
│ ├── book.js # 书籍API
│ └── chapter.js # 章节API
├── router/ # 路由配置
│ └── index.js # 路由入口
├── utils/ # 工具函数
│ ├── request.js # HTTP请求
│ └── format.js # 格式化工具
└── assets/ # 静态资源
├── css/ # 样式文件
└── images/ # 图片资源

3. 依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"name": "book-management-frontend",
"version": "1.0.0",
"dependencies": {
"vue": "^3.2.0",
"vue-router": "^4.0.0",
"vuex": "^4.0.0",
"axios": "^0.27.0",
"element-plus": "^2.0.0",
"@element-plus/icons-vue": "^2.0.0",
"lodash": "^4.17.21",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.0",
"vite": "^3.0.0",
"sass": "^1.54.0",
"eslint": "^8.0.0",
"prettier": "^2.7.0"
}
}

4. 书籍列表组件

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
<!-- BookList.vue -->
<template>
<div class="book-list">
<!-- 搜索栏 -->
<SearchBar
v-model="searchKeyword"
@search="handleSearch"
@clear="handleClearSearch"
/>

<!-- 筛选器 -->
<div class="filter-bar">
<el-select
v-model="selectedCategory"
placeholder="选择分类"
@change="handleCategoryChange"
>
<el-option
v-for="category in categories"
:key="category.value"
:label="category.label"
:value="category.value"
/>
</el-select>

<el-select
v-model="sortBy"
placeholder="排序方式"
@change="handleSortChange"
>
<el-option label="按创建时间" value="createTime" />
<el-option label="按标题" value="title" />
<el-option label="按作者" value="author" />
</el-select>
</div>

<!-- 书籍列表 -->
<div class="book-grid">
<BookCard
v-for="book in paginatedBooks"
:key="book.id"
:book="book"
@click="handleBookClick"
@favorite="handleFavorite"
/>
</div>

<!-- 分页组件 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalBooks"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>

<!-- 加载状态 -->
<el-loading
v-if="loading"
text="加载中..."
background="rgba(0, 0, 0, 0.8)"
/>
</div>
</template>

<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
import BookCard from './BookCard.vue'
import SearchBar from './SearchBar.vue'

export default {
name: 'BookList',
components: {
BookCard,
SearchBar
},
setup() {
const router = useRouter()
const store = useStore()

// 响应式数据
const searchKeyword = ref('')
const selectedCategory = ref('')
const sortBy = ref('createTime')
const currentPage = ref(1)
const pageSize = ref(20)
const loading = ref(false)

// 分类选项
const categories = ref([
{ label: '全部', value: '' },
{ label: '技术', value: 'tech' },
{ label: '文学', value: 'literature' },
{ label: '历史', value: 'history' },
{ label: '科学', value: 'science' }
])

// 计算属性
const books = computed(() => store.getters['book/getBooks'])
const totalBooks = computed(() => store.getters['book/getTotalBooks'])

// 过滤后的书籍列表
const filteredBooks = computed(() => {
let result = books.value

// 按关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(book =>
book.title.toLowerCase().includes(keyword) ||
book.author.toLowerCase().includes(keyword) ||
book.description.toLowerCase().includes(keyword)
)
}

// 按分类筛选
if (selectedCategory.value) {
result = result.filter(book => book.category === selectedCategory.value)
}

// 排序
result.sort((a, b) => {
switch (sortBy.value) {
case 'title':
return a.title.localeCompare(b.title)
case 'author':
return a.author.localeCompare(b.author)
case 'createTime':
default:
return new Date(b.createTime) - new Date(a.createTime)
}
})

return result
})

// 分页后的书籍列表
const paginatedBooks = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredBooks.value.slice(start, end)
})

// 方法
const loadBooks = async () => {
try {
loading.value = true
await store.dispatch('book/fetchBooks', {
page: currentPage.value,
size: pageSize.value,
category: selectedCategory.value,
keyword: searchKeyword.value,
sortBy: sortBy.value
})
} catch (error) {
ElMessage.error('加载书籍列表失败')
console.error('Load books error:', error)
} finally {
loading.value = false
}
}

const handleSearch = () => {
currentPage.value = 1
loadBooks()
}

const handleClearSearch = () => {
searchKeyword.value = ''
currentPage.value = 1
loadBooks()
}

const handleCategoryChange = () => {
currentPage.value = 1
loadBooks()
}

const handleSortChange = () => {
currentPage.value = 1
loadBooks()
}

const handleBookClick = (book) => {
router.push(`/book/${book.id}`)
}

const handleFavorite = async (book) => {
try {
await store.dispatch('book/toggleFavorite', book.id)
ElMessage.success(book.isFavorite ? '已取消收藏' : '已添加收藏')
} catch (error) {
ElMessage.error('操作失败')
console.error('Toggle favorite error:', error)
}
}

const handleSizeChange = (newSize) => {
pageSize.value = newSize
currentPage.value = 1
loadBooks()
}

const handleCurrentChange = (newPage) => {
currentPage.value = newPage
loadBooks()
}

// 监听搜索关键词变化
watch(searchKeyword, (newValue) => {
if (newValue.length >= 2 || newValue.length === 0) {
handleSearch()
}
})

// 生命周期
onMounted(() => {
loadBooks()
})

return {
// 响应式数据
searchKeyword,
selectedCategory,
sortBy,
currentPage,
pageSize,
loading,
categories,

// 计算属性
paginatedBooks,
totalBooks,

// 方法
handleSearch,
handleClearSearch,
handleCategoryChange,
handleSortChange,
handleBookClick,
handleFavorite,
handleSizeChange,
handleCurrentChange
}
}
}
</script>

<style lang="scss" scoped>
.book-list {
padding: 20px;

.filter-bar {
display: flex;
gap: 16px;
margin-bottom: 20px;

.el-select {
width: 200px;
}
}

.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}

.el-pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
}

@media (max-width: 768px) {
.book-list {
padding: 10px;

.book-grid {
grid-template-columns: 1fr;
}

.filter-bar {
flex-direction: column;

.el-select {
width: 100%;
}
}
}
}
</style>

5. 书籍卡片组件

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
<!-- BookCard.vue -->
<template>
<div class="book-card" @click="$emit('click', book)">
<!-- 封面图片 -->
<div class="book-cover">
<img
:src="book.coverUrl || defaultCover"
:alt="book.title"
@error="handleImageError"
/>
<div class="book-overlay">
<el-button
type="primary"
size="small"
@click.stop="$emit('click', book)"
>
查看详情
</el-button>
</div>
</div>

<!-- 书籍信息 -->
<div class="book-info">
<h3 class="book-title" :title="book.title">
{{ book.title }}
</h3>

<p class="book-author">
<el-icon><User /></el-icon>
{{ book.author || '未知作者' }}
</p>

<p class="book-description" :title="book.description">
{{ truncatedDescription }}
</p>

<div class="book-meta">
<span class="book-category">
<el-tag size="small" type="info">
{{ getCategoryName(book.category) }}
</el-tag>
</span>

<span class="book-chapters">
<el-icon><Document /></el-icon>
{{ book.chapterCount || 0 }} 章
</span>
</div>

<div class="book-actions">
<el-button
:type="book.isFavorite ? 'danger' : 'default'"
size="small"
@click.stop="$emit('favorite', book)"
>
<el-icon>
<Star v-if="book.isFavorite" />
<StarFilled v-else />
</el-icon>
{{ book.isFavorite ? '已收藏' : '收藏' }}
</el-button>

<el-button
type="primary"
size="small"
@click.stop="handleRead"
>
<el-icon><Reading /></el-icon>
开始阅读
</el-button>
</div>
</div>
</div>
</template>

<script>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Document, Star, StarFilled, Reading } from '@element-plus/icons-vue'

export default {
name: 'BookCard',
components: {
User,
Document,
Star,
StarFilled,
Reading
},
props: {
book: {
type: Object,
required: true,
validator: (value) => {
return value && typeof value.id !== 'undefined'
}
}
},
emits: ['click', 'favorite'],
setup(props) {
const router = useRouter()

// 默认封面图片
const defaultCover = '/images/default-book-cover.jpg'

// 分类映射
const categoryMap = {
'tech': '技术',
'literature': '文学',
'history': '历史',
'science': '科学'
}

// 计算属性
const truncatedDescription = computed(() => {
const description = props.book.description || ''
return description.length > 100
? description.substring(0, 100) + '...'
: description
})

// 方法
const getCategoryName = (category) => {
return categoryMap[category] || '其他'
}

const handleImageError = (event) => {
event.target.src = defaultCover
}

const handleRead = () => {
if (props.book.chapterCount > 0) {
router.push(`/book/${props.book.id}/chapter/1`)
} else {
ElMessage.warning('该书籍暂无章节内容')
}
}

return {
defaultCover,
truncatedDescription,
getCategoryName,
handleImageError,
handleRead
}
}
}
</script>

<style lang="scss" scoped>
.book-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;

&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);

.book-overlay {
opacity: 1;
}
}

.book-cover {
position: relative;
height: 200px;
overflow: hidden;

img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}

&:hover img {
transform: scale(1.05);
}

.book-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
}

.book-info {
padding: 16px;

.book-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.book-author {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 4px;
}

.book-description {
font-size: 13px;
color: #888;
margin: 0 0 12px 0;
line-height: 1.4;
}

.book-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;

.book-chapters {
font-size: 12px;
color: #666;
display: flex;
align-items: center;
gap: 4px;
}
}

.book-actions {
display: flex;
gap: 8px;

.el-button {
flex: 1;
}
}
}
}

@media (max-width: 768px) {
.book-card {
.book-cover {
height: 150px;
}

.book-info {
padding: 12px;

.book-title {
font-size: 14px;
}

.book-actions {
flex-direction: column;

.el-button {
width: 100%;
}
}
}
}
}
</style>

6. 章节列表组件

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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
<!-- ChapterList.vue -->
<template>
<div class="chapter-list">
<!-- 章节头部信息 -->
<div class="chapter-header">
<div class="book-info">
<img
:src="book.coverUrl || defaultCover"
:alt="book.title"
class="book-cover"
/>
<div class="book-details">
<h1 class="book-title">{{ book.title }}</h1>
<p class="book-author">{{ book.author }}</p>
<p class="book-description">{{ book.description }}</p>
</div>
</div>

<div class="book-actions">
<el-button type="primary" @click="handleStartReading">
<el-icon><Reading /></el-icon>
开始阅读
</el-button>
<el-button @click="handleFavorite">
<el-icon>
<Star v-if="book.isFavorite" />
<StarFilled v-else />
</el-icon>
{{ book.isFavorite ? '已收藏' : '收藏' }}
</el-button>
</div>
</div>

<!-- 章节统计 -->
<div class="chapter-stats">
<el-statistic title="总章节数" :value="chapters.length" />
<el-statistic title="已读章节" :value="readChaptersCount" />
<el-statistic title="阅读进度" :value="readingProgress" suffix="%" />
</div>

<!-- 章节列表 -->
<div class="chapter-content">
<div class="chapter-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索章节"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>

<el-select v-model="sortBy" @change="handleSortChange">
<el-option label="按顺序" value="order" />
<el-option label="按标题" value="title" />
<el-option label="按创建时间" value="createTime" />
</el-select>
</div>

<div class="chapter-items">
<ChapterItem
v-for="chapter in filteredChapters"
:key="chapter.id"
:chapter="chapter"
:is-read="isChapterRead(chapter.id)"
@click="handleChapterClick"
@download="handleChapterDownload"
/>
</div>
</div>
</div>
</template>

<script>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
import { Reading, Star, StarFilled, Search } from '@element-plus/icons-vue'
import ChapterItem from './ChapterItem.vue'

export default {
name: 'ChapterList',
components: {
Reading,
Star,
StarFilled,
Search,
ChapterItem
},
setup() {
const route = useRoute()
const router = useRouter()
const store = useStore()

// 响应式数据
const searchKeyword = ref('')
const sortBy = ref('order')
const defaultCover = '/images/default-book-cover.jpg'

// 计算属性
const book = computed(() => store.getters['book/getCurrentBook'])
const chapters = computed(() => store.getters['chapter/getChapters'])
const readChapters = computed(() => store.getters['chapter/getReadChapters'])

// 过滤后的章节列表
const filteredChapters = computed(() => {
let result = [...chapters.value]

// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(chapter =>
chapter.title.toLowerCase().includes(keyword)
)
}

// 排序
result.sort((a, b) => {
switch (sortBy.value) {
case 'title':
return a.title.localeCompare(b.title)
case 'createTime':
return new Date(b.createTime) - new Date(a.createTime)
case 'order':
default:
return a.chapterOrder - b.chapterOrder
}
})

return result
})

// 已读章节数量
const readChaptersCount = computed(() => {
return readChapters.value.length
})

// 阅读进度
const readingProgress = computed(() => {
if (chapters.value.length === 0) return 0
return Math.round((readChaptersCount.value / chapters.value.length) * 100)
})

// 方法
const loadBookAndChapters = async () => {
try {
const bookId = route.params.bookId
await store.dispatch('book/fetchBookDetail', bookId)
await store.dispatch('chapter/fetchChapters', bookId)
await store.dispatch('chapter/fetchReadChapters', bookId)
} catch (error) {
ElMessage.error('加载书籍信息失败')
console.error('Load book error:', error)
}
}

const handleSearch = () => {
// 搜索逻辑已在计算属性中处理
}

const handleSortChange = () => {
// 排序逻辑已在计算属性中处理
}

const handleStartReading = () => {
if (chapters.value.length > 0) {
const firstChapter = chapters.value[0]
router.push(`/book/${book.value.id}/chapter/${firstChapter.id}`)
} else {
ElMessage.warning('该书籍暂无章节内容')
}
}

const handleFavorite = async () => {
try {
await store.dispatch('book/toggleFavorite', book.value.id)
ElMessage.success(book.value.isFavorite ? '已取消收藏' : '已添加收藏')
} catch (error) {
ElMessage.error('操作失败')
console.error('Toggle favorite error:', error)
}
}

const handleChapterClick = (chapter) => {
router.push(`/book/${book.value.id}/chapter/${chapter.id}`)
}

const handleChapterDownload = async (chapter) => {
try {
const downloadUrl = await store.dispatch('chapter/generateDownloadUrl', chapter.id)
window.open(downloadUrl, '_blank')
} catch (error) {
ElMessage.error('下载失败')
console.error('Download error:', error)
}
}

const isChapterRead = (chapterId) => {
return readChapters.value.includes(chapterId)
}

// 生命周期
onMounted(() => {
loadBookAndChapters()
})

return {
// 响应式数据
searchKeyword,
sortBy,
defaultCover,

// 计算属性
book,
filteredChapters,
readChaptersCount,
readingProgress,

// 方法
handleSearch,
handleSortChange,
handleStartReading,
handleFavorite,
handleChapterClick,
handleChapterDownload,
isChapterRead
}
}
}
</script>

<style lang="scss" scoped>
.chapter-list {
padding: 20px;

.chapter-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

.book-info {
display: flex;
gap: 20px;

.book-cover {
width: 120px;
height: 160px;
object-fit: cover;
border-radius: 4px;
}

.book-details {
flex: 1;

.book-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
}

.book-author {
font-size: 16px;
color: #666;
margin: 0 0 12px 0;
}

.book-description {
font-size: 14px;
color: #888;
line-height: 1.5;
margin: 0;
}
}
}

.book-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
}

.chapter-stats {
display: flex;
gap: 40px;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.chapter-content {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

.chapter-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #eee;

.el-input {
width: 300px;
}

.el-select {
width: 150px;
}
}

.chapter-items {
padding: 20px;
}
}
}

@media (max-width: 768px) {
.chapter-list {
padding: 10px;

.chapter-header {
flex-direction: column;
gap: 20px;

.book-info {
flex-direction: column;
text-align: center;

.book-cover {
width: 100px;
height: 130px;
margin: 0 auto;
}
}

.book-actions {
flex-direction: row;
justify-content: center;
}
}

.chapter-stats {
flex-direction: column;
gap: 20px;
}

.chapter-content {
.chapter-toolbar {
flex-direction: column;
gap: 12px;

.el-input,
.el-select {
width: 100%;
}
}
}
}
}
</style>

7. 章节项组件

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
<!-- ChapterItem.vue -->
<template>
<div
class="chapter-item"
:class="{ 'is-read': isRead, 'is-current': isCurrentChapter }"
@click="$emit('click', chapter)"
>
<div class="chapter-info">
<div class="chapter-number">
{{ chapter.chapterOrder }}
</div>

<div class="chapter-content">
<h3 class="chapter-title">{{ chapter.title }}</h3>
<p class="chapter-meta">
<span class="chapter-size">{{ formatFileSize(chapter.fileSize) }}</span>
<span class="chapter-date">{{ formatDate(chapter.createTime) }}</span>
</p>
</div>

<div class="chapter-status">
<el-icon v-if="isRead" class="read-icon">
<Check />
</el-icon>
<el-icon v-if="isCurrentChapter" class="current-icon">
<Play />
</el-icon>
</div>
</div>

<div class="chapter-actions">
<el-button
size="small"
type="primary"
@click.stop="$emit('click', chapter)"
>
阅读
</el-button>

<el-button
size="small"
@click.stop="$emit('download', chapter)"
>
<el-icon><Download /></el-icon>
下载
</el-button>
</div>
</div>
</template>

<script>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Check, Play, Download } from '@element-plus/icons-vue'
import { formatFileSize, formatDate } from '@/utils/format'

export default {
name: 'ChapterItem',
components: {
Check,
Play,
Download
},
props: {
chapter: {
type: Object,
required: true
},
isRead: {
type: Boolean,
default: false
}
},
emits: ['click', 'download'],
setup(props) {
const route = useRoute()

// 计算属性
const isCurrentChapter = computed(() => {
return route.params.chapterId === props.chapter.id
})

return {
isCurrentChapter,
formatFileSize,
formatDate
}
}
}
</script>

<style lang="scss" scoped>
.chapter-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s ease;

&:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}

&.is-read {
background-color: #f0f9ff;
border-color: #67c23a;

.chapter-title {
color: #67c23a;
}
}

&.is-current {
background-color: #e6f7ff;
border-color: #409eff;

.chapter-title {
color: #409eff;
font-weight: 600;
}
}

.chapter-info {
display: flex;
align-items: center;
gap: 16px;
flex: 1;

.chapter-number {
width: 40px;
height: 40px;
background: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #666;
}

.chapter-content {
flex: 1;

.chapter-title {
font-size: 16px;
font-weight: 500;
margin: 0 0 4px 0;
color: #333;
}

.chapter-meta {
font-size: 12px;
color: #999;
margin: 0;
display: flex;
gap: 16px;
}
}

.chapter-status {
display: flex;
gap: 8px;

.read-icon {
color: #67c23a;
font-size: 18px;
}

.current-icon {
color: #409eff;
font-size: 18px;
}
}
}

.chapter-actions {
display: flex;
gap: 8px;
}
}

@media (max-width: 768px) {
.chapter-item {
flex-direction: column;
align-items: stretch;
gap: 12px;

.chapter-info {
.chapter-content {
.chapter-meta {
flex-direction: column;
gap: 4px;
}
}
}

.chapter-actions {
justify-content: center;
}
}
}
</style>

8. 搜索栏组件

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
<!-- SearchBar.vue -->
<template>
<div class="search-bar">
<el-input
:model-value="modelValue"
placeholder="搜索书籍、作者或描述..."
@input="$emit('update:modelValue', $event)"
@keyup.enter="$emit('search')"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>

<el-button
type="primary"
@click="$emit('search')"
>
搜索
</el-button>

<el-button
v-if="modelValue"
@click="$emit('clear')"
>
清除
</el-button>
</div>
</template>

<script>
import { Search } from '@element-plus/icons-vue'

export default {
name: 'SearchBar',
components: {
Search
},
props: {
modelValue: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'search', 'clear']
}
</script>

<style lang="scss" scoped>
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;

.el-input {
flex: 1;
}
}

@media (max-width: 768px) {
.search-bar {
flex-direction: column;

.el-input {
width: 100%;
}
}
}
</style>

9. 状态管理

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
// store/modules/book.js
export default {
namespaced: true,

state: {
books: [],
currentBook: null,
totalBooks: 0,
loading: false,
error: null
},

getters: {
getBooks: (state) => state.books,
getCurrentBook: (state) => state.currentBook,
getTotalBooks: (state) => state.totalBooks,
isLoading: (state) => state.loading,
getError: (state) => state.error
},

mutations: {
SET_BOOKS(state, books) {
state.books = books
},

SET_CURRENT_BOOK(state, book) {
state.currentBook = book
},

SET_TOTAL_BOOKS(state, total) {
state.totalBooks = total
},

SET_LOADING(state, loading) {
state.loading = loading
},

SET_ERROR(state, error) {
state.error = error
},

UPDATE_BOOK_FAVORITE(state, { bookId, isFavorite }) {
const book = state.books.find(b => b.id === bookId)
if (book) {
book.isFavorite = isFavorite
}
if (state.currentBook && state.currentBook.id === bookId) {
state.currentBook.isFavorite = isFavorite
}
}
},

actions: {
async fetchBooks({ commit }, params) {
try {
commit('SET_LOADING', true)
commit('SET_ERROR', null)

const response = await bookApi.getBooks(params)

commit('SET_BOOKS', response.data.books)
commit('SET_TOTAL_BOOKS', response.data.total)

} catch (error) {
commit('SET_ERROR', error.message)
throw error
} finally {
commit('SET_LOADING', false)
}
},

async fetchBookDetail({ commit }, bookId) {
try {
commit('SET_LOADING', true)
commit('SET_ERROR', null)

const response = await bookApi.getBookDetail(bookId)

commit('SET_CURRENT_BOOK', response.data)

} catch (error) {
commit('SET_ERROR', error.message)
throw error
} finally {
commit('SET_LOADING', false)
}
},

async toggleFavorite({ commit }, bookId) {
try {
const response = await bookApi.toggleFavorite(bookId)

commit('UPDATE_BOOK_FAVORITE', {
bookId,
isFavorite: response.data.isFavorite
})

} catch (error) {
throw error
}
}
}
}

10. API接口

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
// api/book.js
import request from '@/utils/request'

export const bookApi = {
// 获取书籍列表
getBooks(params) {
return request({
url: '/api/book/list',
method: 'get',
params
})
},

// 获取书籍详情
getBookDetail(bookId) {
return request({
url: `/api/book/${bookId}`,
method: 'get'
})
},

// 切换收藏状态
toggleFavorite(bookId) {
return request({
url: `/api/book/${bookId}/favorite`,
method: 'post'
})
},

// 搜索书籍
searchBooks(keyword) {
return request({
url: '/api/book/search',
method: 'get',
params: { keyword }
})
}
}

// api/chapter.js
export const chapterApi = {
// 获取章节列表
getChapters(bookId) {
return request({
url: `/api/book/${bookId}/chapters`,
method: 'get'
})
},

// 获取章节详情
getChapterDetail(chapterId) {
return request({
url: `/api/chapter/${chapterId}`,
method: 'get'
})
},

// 生成下载链接
generateDownloadUrl(chapterId) {
return request({
url: `/api/chapter/${chapterId}/download`,
method: 'post'
})
},

// 标记章节为已读
markAsRead(chapterId) {
return request({
url: `/api/chapter/${chapterId}/read`,
method: 'post'
})
}
}

11. 路由配置

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
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import BookDetail from '@/views/BookDetail.vue'
import ChapterDetail from '@/views/ChapterDetail.vue'

const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/book/:bookId',
name: 'BookDetail',
component: BookDetail,
props: true
},
{
path: '/book/:bookId/chapter/:chapterId',
name: 'ChapterDetail',
component: ChapterDetail,
props: true
}
]

const router = createRouter({
history: createWebHistory(),
routes
})

export default router

12. 总结

前端书籍目录列表分章节管理是Vue组件化开发的重要应用。通过本文的详细介绍,我们了解了:

  1. 组件化设计: 合理的组件拆分和复用
  2. 状态管理: 使用Vuex管理应用状态
  3. 路由配置: 实现页面导航和参数传递
  4. API集成: 与后端服务的数据交互
  5. 响应式设计: 适配不同设备屏幕

通过合理的架构设计和组件化开发,可以为用户提供良好的阅读体验。


Vue实战要点:

  • 组件化开发提高代码复用性和可维护性
  • 使用Vuex管理复杂的状态逻辑
  • 响应式设计适配不同设备
  • 合理的API设计提高开发效率
  • 良好的用户体验设计

代码注解说明:

  • setup(): Vue 3 Composition API入口
  • computed(): 计算属性,自动缓存依赖
  • ref(): 响应式引用
  • watch(): 监听数据变化
  • emit(): 子组件向父组件传递事件
  • props(): 父组件向子组件传递数据