Vue.js 项目实战案例(上)

本章将带领大家完成三个完整的 Vue.js 实战项目,从简单到复杂,循序渐进地掌握 Vue.js 的实际应用能力。

目录

  1. 项目一:Todo List 待办事项应用
  2. 项目二:用户管理系统
  3. 项目三:电商后台管理系统

项目一:Todo List 待办事项应用

项目简介

这是一个功能完整的待办事项管理应用,包含增删改查、本地存储、筛选过滤等核心功能。

功能特性

  • ✅ 添加待办事项
  • ✅ 编辑待办事项
  • ✅ 删除待办事项
  • ✅ 标记完成状态
  • ✅ 本地存储持久化
  • ✅ 状态筛选(全部/未完成/已完成)
  • ✅ 统计信息展示

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
todo-app/
├── public/
│ └── index.html
├── src/
│ ├── assets/
│ │ └── styles/
│ │ └── main.css
│ ├── components/
│ │ ├── TodoInput.vue
│ │ ├── TodoList.vue
│ │ ├── TodoItem.vue
│ │ ├── TodoFilter.vue
│ │ └── TodoStats.vue
│ ├── composables/
│ │ └── useTodo.js
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js

完整代码实现

1. main.js - 应用入口

1
2
3
4
5
import { createApp } from 'vue'
import App from './App.vue'
import './assets/styles/main.css'

createApp(App).mount('#app')

2. App.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
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
<template>
<div class="todo-app">
<header class="app-header">
<h1>📝 待办事项管理</h1>
<p class="subtitle">高效管理您的每一天</p>
</header>

<main class="app-main">
<TodoInput @add="addTodo" />
<TodoFilter
:currentFilter="currentFilter"
@filter-change="setFilter"
/>
<TodoList
:todos="filteredTodos"
@toggle="toggleTodo"
@delete="deleteTodo"
@edit="editTodo"
/>
<TodoStats :stats="todoStats" @clear-completed="clearCompleted" />
</main>
</div>
</template>

<script setup>
import { computed } from 'vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
import TodoFilter from './components/TodoFilter.vue'
import TodoStats from './components/TodoStats.vue'
import { useTodo } from './composables/useTodo'

const {
todos,
currentFilter,
addTodo,
toggleTodo,
deleteTodo,
editTodo,
setFilter,
clearCompleted
} = useTodo()

// 筛选后的待办列表
const filteredTodos = computed(() => {
switch (currentFilter.value) {
case 'active':
return todos.value.filter(todo => !todo.completed)
case 'completed':
return todos.value.filter(todo => todo.completed)
default:
return todos.value
}
})

// 统计信息
const todoStats = computed(() => ({
total: todos.value.length,
completed: todos.value.filter(t => t.completed).length,
active: todos.value.filter(t => !t.completed).length
}))
</script>

<style scoped>
.todo-app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.app-header {
text-align: center;
margin-bottom: 30px;
color: white;
}

.app-header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}

.subtitle {
font-size: 1rem;
opacity: 0.9;
}

.app-main {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
</style>

3. composables/useTodo.js - 状态管理

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
import { ref, watch } from 'vue'

export function useTodo() {
// 从本地存储加载
const loadTodos = () => {
const saved = localStorage.getItem('todos')
return saved ? JSON.parse(saved) : []
}

const todos = ref(loadTodos())
const currentFilter = ref('all')

// 监听变化,自动保存
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// 添加待办
const addTodo = (text) => {
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date().toISOString()
}
todos.value.unshift(newTodo)
}

// 切换完成状态
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}

// 删除待办
const deleteTodo = (id) => {
const index = todos.value.findIndex(t => t.id === id)
if (index !== -1) {
todos.value.splice(index, 1)
}
}

// 编辑待办
const editTodo = (id, newText) => {
const todo = todos.value.find(t => t.id === id)
if (todo && newText.trim()) {
todo.text = newText.trim()
}
}

// 设置筛选
const setFilter = (filter) => {
currentFilter.value = filter
}

// 清除已完成
const clearCompleted = () => {
todos.value = todos.value.filter(t => !t.completed)
}

return {
todos,
currentFilter,
addTodo,
toggleTodo,
deleteTodo,
editTodo,
setFilter,
clearCompleted
}
}

4. components/TodoInput.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
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
<template>
<div class="todo-input">
<input
v-model="inputText"
@keyup.enter="handleSubmit"
placeholder="添加新的待办事项..."
class="input-field"
/>
<button @click="handleSubmit" class="add-btn">
<span>添加</span>
</button>
</div>
</template>

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['add'])
const inputText = ref('')

const handleSubmit = () => {
if (inputText.value.trim()) {
emit('add', inputText.value)
inputText.value = ''
}
}
</script>

<style scoped>
.todo-input {
display: flex;
gap: 10px;
margin-bottom: 20px;
}

.input-field {
flex: 1;
padding: 15px 20px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
}

.input-field:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.add-btn {
padding: 15px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}

.add-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}

.add-btn:active {
transform: translateY(0);
}
</style>

5. components/TodoItem.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
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
<template>
<div class="todo-item" :class="{ completed: todo.completed }">
<div class="todo-content">
<input
type="checkbox"
:checked="todo.completed"
@change="$emit('toggle', todo.id)"
class="checkbox"
/>

<div v-if="!isEditing" class="todo-text" @dblclick="startEdit">
{{ todo.text }}
</div>

<input
v-else
v-model="editText"
@blur="finishEdit"
@keyup.enter="finishEdit"
@keyup.escape="cancelEdit"
ref="editInput"
class="edit-input"
/>
</div>

<div class="todo-actions">
<button @click="startEdit" class="edit-btn" v-if="!isEditing">
✏️
</button>
<button @click="$emit('delete', todo.id)" class="delete-btn">
🗑️
</button>
</div>
</div>
</template>

<script setup>
import { ref, nextTick } from 'vue'

const props = defineProps({
todo: {
type: Object,
required: true
}
})

const emit = defineEmits(['toggle', 'delete', 'edit'])

const isEditing = ref(false)
const editText = ref('')
const editInput = ref(null)

const startEdit = () => {
isEditing.value = true
editText.value = props.todo.text
nextTick(() => {
editInput.value?.focus()
})
}

const finishEdit = () => {
if (editText.value.trim() && editText.value !== props.todo.text) {
emit('edit', props.todo.id, editText.value)
}
isEditing.value = false
}

const cancelEdit = () => {
isEditing.value = false
editText.value = props.todo.text
}
</script>

<style scoped>
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 10px;
transition: all 0.3s ease;
}

.todo-item:hover {
background: #e9ecef;
transform: translateX(5px);
}

.todo-item.completed {
opacity: 0.6;
}

.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}

.todo-content {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}

.checkbox {
width: 22px;
height: 22px;
cursor: pointer;
accent-color: #667eea;
}

.todo-text {
flex: 1;
font-size: 1rem;
cursor: pointer;
}

.edit-input {
flex: 1;
padding: 8px 12px;
border: 2px solid #667eea;
border-radius: 5px;
font-size: 1rem;
}

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

.edit-btn,
.delete-btn {
padding: 8px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
background: transparent;
}

.edit-btn:hover {
background: #ffc107;
}

.delete-btn:hover {
background: #dc3545;
color: white;
}
</style>

6. components/TodoList.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
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
<template>
<div class="todo-list">
<transition-group name="list" tag="div">
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="$emit('toggle', $event)"
@delete="$emit('delete', $event)"
@edit="(id, text) => $emit('edit', id, text)"
/>
</transition-group>

<div v-if="todos.length === 0" class="empty-state">
<p>🎉 暂无待办事项</p>
</div>
</div>
</template>

<script setup>
import TodoItem from './TodoItem.vue'

defineProps({
todos: {
type: Array,
required: true
}
})

defineEmits(['toggle', 'delete', 'edit'])
</script>

<style scoped>
.todo-list {
min-height: 200px;
}

.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}

.empty-state {
text-align: center;
padding: 40px;
color: #999;
font-size: 1.2rem;
}
</style>

7. components/TodoFilter.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
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
<template>
<div class="todo-filter">
<button
v-for="filter in filters"
:key="filter.value"
:class="['filter-btn', { active: currentFilter === filter.value }]"
@click="$emit('filter-change', filter.value)"
>
{{ filter.label }}
</button>
</div>
</template>

<script setup>
defineProps({
currentFilter: {
type: String,
default: 'all'
}
})

defineEmits(['filter-change'])

const filters = [
{ value: 'all', label: '全部' },
{ value: 'active', label: '未完成' },
{ value: 'completed', label: '已完成' }
]
</script>

<style scoped>
.todo-filter {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}

.filter-btn {
padding: 10px 20px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
}

.filter-btn:hover {
border-color: #667eea;
}

.filter-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
</style>

8. components/TodoStats.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
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
<template>
<div class="todo-stats">
<div class="stats-info">
<span>总计: {{ stats.total }}</span>
<span>已完成: {{ stats.completed }}</span>
<span>未完成: {{ stats.active }}</span>
</div>

<button
v-if="stats.completed > 0"
@click="$emit('clear-completed')"
class="clear-btn"
>
清除已完成
</button>
</div>
</template>

<script setup>
defineProps({
stats: {
type: Object,
required: true
}
})

defineEmits(['clear-completed'])
</script>

<style scoped>
.todo-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #e0e0e0;
}

.stats-info {
display: flex;
gap: 20px;
color: #666;
font-size: 0.9rem;
}

.clear-btn {
padding: 10px 20px;
background: #dc3545;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}

.clear-btn:hover {
background: #c82333;
transform: translateY(-2px);
}
</style>

项目特点总结

  1. 组件化设计:将功能拆分为独立的可复用组件
  2. 状态管理:使用 Composition API 管理状态
  3. 本地持久化:利用 LocalStorage 保存数据
  4. 响应式更新:自动同步 UI 与数据
  5. 动画效果:添加流畅的过渡动画

项目二:用户管理系统

项目简介

这是一个企业级用户管理系统,包含完整的增删改查、分页、搜索、表单验证等功能。

功能特性

  • ✅ 用户列表展示(DataTable)
  • ✅ 分页功能
  • ✅ 搜索过滤
  • ✅ 添加/编辑用户
  • ✅ 表单验证
  • ✅ 批量操作
  • ✅ 数据导出

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
user-management/
├── public/
│ └── index.html
├── src/
│ ├── api/
│ │ └── user.js
│ ├── components/
│ │ ├── UserTable.vue
│ │ ├── UserForm.vue
│ │ ├── Pagination.vue
│ │ ├── SearchBar.vue
│ │ └── Modal.vue
│ ├── stores/
│ │ └── userStore.js
│ ├── router/
│ │ └── index.js
│ ├── views/
│ │ └── UserManagement.vue
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js

完整代码实现

1. main.js - 应用入口

1
2
3
4
5
6
7
8
9
10
11
12
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './assets/styles/main.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

2. router/index.js - 路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createRouter, createWebHistory } from 'vue-router'
import UserManagement from '../views/UserManagement.vue'

const routes = [
{
path: '/',
redirect: '/users'
},
{
path: '/users',
name: 'UserManagement',
component: UserManagement
}
]

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

export default router

3. stores/userStore.js - Pinia 状态管理

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
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { userApi } from '../api/user'

export const useUserStore = defineStore('user', () => {
// 状态
const users = ref([])
const currentUser = ref(null)
const loading = ref(false)
const error = ref(null)
const pagination = ref({
page: 1,
pageSize: 10,
total: 0
})
const searchQuery = ref('')
const selectedUsers = ref([])

// 计算属性
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value

const query = searchQuery.value.toLowerCase()
return users.value.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.department.toLowerCase().includes(query)
)
})

// 异步操作
const fetchUsers = async (params = {}) => {
loading.value = true
error.value = null

try {
const response = await userApi.getUsers({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
...params
})

users.value = response.data
pagination.value.total = response.total
} catch (err) {
error.value = err.message
console.error('获取用户列表失败:', err)
} finally {
loading.value = false
}
}

const addUser = async (userData) => {
loading.value = true
try {
const newUser = await userApi.createUser(userData)
users.value.unshift(newUser)
return newUser
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}

const updateUser = async (id, userData) => {
loading.value = true
try {
const updatedUser = await userApi.updateUser(id, userData)
const index = users.value.findIndex(u => u.id === id)
if (index !== -1) {
users.value[index] = updatedUser
}
return updatedUser
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}

const deleteUser = async (id) => {
loading.value = true
try {
await userApi.deleteUser(id)
users.value = users.value.filter(u => u.id !== id)
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}

const batchDeleteUsers = async (ids) => {
loading.value = true
try {
await userApi.batchDeleteUsers(ids)
users.value = users.value.filter(u => !ids.includes(u.id))
selectedUsers.value = []
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}

const setSearchQuery = (query) => {
searchQuery.value = query
}

const setPage = (page) => {
pagination.value.page = page
fetchUsers()
}

const toggleUserSelection = (userId) => {
const index = selectedUsers.value.indexOf(userId)
if (index === -1) {
selectedUsers.value.push(userId)
} else {
selectedUsers.value.splice(index, 1)
}
}

const toggleAllSelection = () => {
if (selectedUsers.value.length === users.value.length) {
selectedUsers.value = []
} else {
selectedUsers.value = users.value.map(u => u.id)
}
}

return {
users,
currentUser,
loading,
error,
pagination,
searchQuery,
selectedUsers,
filteredUsers,
fetchUsers,
addUser,
updateUser,
deleteUser,
batchDeleteUsers,
setSearchQuery,
setPage,
toggleUserSelection,
toggleAllSelection
}
})

4. api/user.js - 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
74
75
76
import axios from 'axios'

// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})

// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)

// 响应拦截器
api.interceptors.response.use(
response => response.data,
error => {
const message = error.response?.data?.message || '请求失败'
return Promise.reject(new Error(message))
}
)

// 用户 API
export const userApi = {
// 获取用户列表
getUsers(params) {
return api.get('/users', { params })
},

// 获取单个用户
getUser(id) {
return api.get(`/users/${id}`)
},

// 创建用户
createUser(data) {
return api.post('/users', data)
},

// 更新用户
updateUser(id, data) {
return api.put(`/users/${id}`, data)
},

// 删除用户
deleteUser(id) {
return api.delete(`/users/${id}`)
},

// 批量删除用户
batchDeleteUsers(ids) {
return api.post('/users/batch-delete', { ids })
},

// 导出用户数据
exportUsers(params) {
return api.get('/users/export', {
params,
responseType: 'blob'
})
}
}

export default api

5. views/UserManagement.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
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
<template>
<div class="user-management">
<header class="page-header">
<h1>👥 用户管理系统</h1>
<p class="subtitle">管理和维护系统用户信息</p>
</header>

<div class="toolbar">
<SearchBar
:value="searchQuery"
@update:value="setSearchQuery"
placeholder="搜索用户名、邮箱、部门..."
/>

<div class="toolbar-actions">
<button @click="showAddModal = true" class="btn btn-primary">
➕ 添加用户
</button>

<button
v-if="selectedUsers.length > 0"
@click="handleBatchDelete"
class="btn btn-danger"
>
🗑️ 批量删除 ({{ selectedUsers.length }})
</button>

<button @click="handleExport" class="btn btn-secondary">
📊 导出数据
</button>
</div>
</div>

<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>

<div v-else-if="error" class="error">
<p>❌ {{ error }}</p>
<button @click="fetchUsers" class="btn btn-primary">重试</button>
</div>

<UserTable
v-else
:users="filteredUsers"
:selectedUsers="selectedUsers"
@toggle-selection="toggleUserSelection"
@toggle-all="toggleAllSelection"
@edit="handleEdit"
@delete="handleDelete"
/>

<Pagination
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
@page-change="setPage"
/>

<!-- 添加/编辑用户模态框 -->
<Modal
:show="showAddModal || showEditModal"
:title="showEditModal ? '编辑用户' : '添加用户'"
@close="closeModals"
>
<UserForm
:user="currentUser"
@submit="handleSubmit"
@cancel="closeModals"
/>
</Modal>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../stores/userStore'
import UserTable from '../components/UserTable.vue'
import UserForm from '../components/UserForm.vue'
import Pagination from '../components/Pagination.vue'
import SearchBar from '../components/SearchBar.vue'
import Modal from '../components/Modal.vue'

const userStore = useUserStore()
const {
filteredUsers,
loading,
error,
pagination,
searchQuery,
selectedUsers,
currentUser
} = storeToRefs(userStore)

const {
fetchUsers,
addUser,
updateUser,
deleteUser,
batchDeleteUsers,
setSearchQuery,
setPage,
toggleUserSelection,
toggleAllSelection
} = userStore

const showAddModal = ref(false)
const showEditModal = ref(false)

onMounted(() => {
fetchUsers()
})

const handleEdit = (user) => {
currentUser.value = { ...user }
showEditModal.value = true
}

const handleDelete = async (id) => {
if (confirm('确定要删除此用户吗?')) {
await deleteUser(id)
}
}

const handleBatchDelete = async () => {
if (confirm(`确定要删除选中的 ${selectedUsers.value.length} 个用户吗?`)) {
await batchDeleteUsers(selectedUsers.value)
}
}

const handleSubmit = async (userData) => {
try {
if (showEditModal.value) {
await updateUser(currentUser.value.id, userData)
} else {
await addUser(userData)
}
closeModals()
} catch (err) {
alert('操作失败: ' + err.message)
}
}

const handleExport = async () => {
try {
const blob = await userStore.exportUsers()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `users_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
} catch (err) {
alert('导出失败: ' + err.message)
}
}

const closeModals = () => {
showAddModal.value = false
showEditModal.value = false
currentUser.value = null
}
</script>

<style scoped>
.user-management {
max-width: 1400px;
margin: 0 auto;
padding: 30px;
}

.page-header {
margin-bottom: 30px;
}

.page-header h1 {
font-size: 2rem;
color: #333;
margin-bottom: 10px;
}

.subtitle {
color: #666;
font-size: 1rem;
}

.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
flex-wrap: wrap;
}

.toolbar-actions {
display: flex;
gap: 10px;
}

.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
}

.btn:hover {
transform: translateY(-2px);
}

.btn-primary {
background: #667eea;
color: white;
}

.btn-secondary {
background: #6c757d;
color: white;
}

.btn-danger {
background: #dc3545;
color: white;
}

.loading,
.error {
text-align: center;
padding: 40px;
}

.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

6. components/UserTable.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
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
<template>
<div class="user-table">
<table>
<thead>
<tr>
<th class="checkbox-col">
<input
type="checkbox"
:checked="isAllSelected"
@change="$emit('toggle-all')"
/>
</th>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>部门</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<input
type="checkbox"
:checked="selectedUsers.includes(user.id)"
@change="$emit('toggle-selection', user.id)"
/>
</td>
<td>{{ user.id }}</td>
<td>
<div class="user-info">
<img :src="user.avatar" :alt="user.name" class="avatar" />
<span>{{ user.name }}</span>
</div>
</td>
<td>{{ user.email }}</td>
<td>{{ user.department }}</td>
<td>
<span class="badge" :class="user.role">
{{ user.role }}
</span>
</td>
<td>
<span class="status" :class="user.status">
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td>{{ formatDate(user.createdAt) }}</td>
<td>
<div class="actions">
<button @click="$emit('edit', user)" class="action-btn edit">
✏️
</button>
<button @click="$emit('delete', user.id)" class="action-btn delete">
🗑️
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
users: Array,
selectedUsers: Array
})

defineEmits(['toggle-selection', 'toggle-all', 'edit', 'delete'])

const isAllSelected = computed(() => {
return props.users.length > 0 &&
props.users.every(u => props.selectedUsers.includes(u.id))
})

const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>

<style scoped>
.user-table {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

table {
width: 100%;
border-collapse: collapse;
}

th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #eee;
}

th {
background: #f8f9fa;
font-weight: 600;
color: #333;
}

.checkbox-col {
width: 50px;
}

.user-info {
display: flex;
align-items: center;
gap: 10px;
}

.avatar {
width: 35px;
height: 35px;
border-radius: 50%;
object-fit: cover;
}

.badge {
padding: 5px 12px;
border-radius: 15px;
font-size: 0.85rem;
font-weight: 500;
}

.badge.admin {
background: #e3f2fd;
color: #1976d2;
}

.badge.user {
background: #f3e5f5;
color: #7b1fa2;
}

.status {
padding: 5px 12px;
border-radius: 15px;
font-size: 0.85rem;
}

.status.active {
background: #e8f5e9;
color: #388e3c;
}

.status.inactive {
background: #ffebee;
color: #d32f2f;
}

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

.action-btn {
padding: 6px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}

.action-btn.edit:hover {
background: #ffc107;
}

.action-btn.delete:hover {
background: #dc3545;
color: white;
}
</style>

7. components/UserForm.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
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
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label>用户名 *</label>
<input
v-model="formData.name"
type="text"
placeholder="请输入用户名"
:class="{ error: errors.name }"
/>
<span v-if="errors.name" class="error-text">{{ errors.name }}</span>
</div>

<div class="form-group">
<label>邮箱 *</label>
<input
v-model="formData.email"
type="email"
placeholder="请输入邮箱"
:class="{ error: errors.email }"
/>
<span v-if="errors.email" class="error-text">{{ errors.email }}</span>
</div>

<div class="form-row">
<div class="form-group">
<label>部门 *</label>
<select v-model="formData.department" :class="{ error: errors.department }">
<option value="">请选择部门</option>
<option value="技术部">技术部</option>
<option value="产品部">产品部</option>
<option value="设计部">设计部</option>
<option value="市场部">市场部</option>
<option value="人事部">人事部</option>
</select>
<span v-if="errors.department" class="error-text">{{ errors.department }}</span>
</div>

<div class="form-group">
<label>角色</label>
<select v-model="formData.role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>

<div class="form-group">
<label>电话</label>
<input
v-model="formData.phone"
type="tel"
placeholder="请输入电话号码"
/>
</div>

<div class="form-group">
<label>状态</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="formData.status" value="active" />
<span>启用</span>
</label>
<label class="radio-label">
<input type="radio" v-model="formData.status" value="inactive" />
<span>禁用</span>
</label>
</div>
</div>

<div class="form-actions">
<button type="button" @click="$emit('cancel')" class="btn btn-cancel">
取消
</button>
<button type="submit" class="btn btn-submit">
{{ user ? '更新' : '添加' }}
</button>
</div>
</form>
</template>

<script setup>
import { ref, reactive, watch } from 'vue'

const props = defineProps({
user: Object
})

const emit = defineEmits(['submit', 'cancel'])

const formData = reactive({
name: '',
email: '',
department: '',
role: 'user',
phone: '',
status: 'active'
})

const errors = reactive({})

// 初始化表单数据
watch(() => props.user, (newUser) => {
if (newUser) {
Object.assign(formData, newUser)
}
}, { immediate: true })

const validateForm = () => {
const newErrors = {}

if (!formData.name.trim()) {
newErrors.name = '请输入用户名'
} else if (formData.name.length < 2) {
newErrors.name = '用户名至少2个字符'
}

if (!formData.email.trim()) {
newErrors.email = '请输入邮箱'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = '邮箱格式不正确'
}

if (!formData.department) {
newErrors.department = '请选择部门'
}

Object.assign(errors, newErrors)
return Object.keys(newErrors).length === 0
}

const handleSubmit = () => {
if (validateForm()) {
emit('submit', { ...formData })
}
}
</script>

<style scoped>
.user-form {
padding: 20px;
}

.form-group {
margin-bottom: 20px;
}

.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}

.form-group input,
.form-group select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
}

.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}

.form-group input.error,
.form-group select.error {
border-color: #dc3545;
}

.error-text {
color: #dc3545;
font-size: 0.85rem;
margin-top: 5px;
display: block;
}

.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}

.radio-group {
display: flex;
gap: 20px;
}

.radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}

.form-actions {
display: flex;
justify-content: flex-end;
gap: 15px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}

.btn {
padding: 12px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}

.btn-cancel {
background: #f8f9fa;
color: #666;
}

.btn-cancel:hover {
background: #e9ecef;
}

.btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}

.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
</style>

8. components/Pagination.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
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
<template>
<div class="pagination">
<button
:disabled="currentPage === 1"
@click="$emit('page-change', currentPage - 1)"
class="page-btn"
>
上一页
</button>

<div class="page-numbers">
<button
v-for="page in displayPages"
:key="page"
:class="['page-num', { active: page === currentPage }]"
@click="$emit('page-change', page)"
>
{{ page }}
</button>
</div>

<button
:disabled="currentPage === totalPages"
@click="$emit('page-change', currentPage + 1)"
class="page-btn"
>
下一页
</button>

<span class="page-info">
共 {{ total }} 条
</span>
</div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
currentPage: {
type: Number,
required: true
},
pageSize: {
type: Number,
default: 10
},
total: {
type: Number,
required: true
}
})

defineEmits(['page-change'])

const totalPages = computed(() => {
return Math.ceil(props.total / props.pageSize)
})

const displayPages = computed(() => {
const pages = []
const total = totalPages.value
const current = props.currentPage

let start = Math.max(1, current - 2)
let end = Math.min(total, current + 2)

if (start > 1) pages.push(1)
if (start > 2) pages.push('...')

for (let i = start; i <= end; i++) {
pages.push(i)
}

if (end < total - 1) pages.push('...')
if (end < total) pages.push(total)

return pages
})
</script>

<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 30px;
padding: 20px;
}

.page-btn {
padding: 10px 20px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}

.page-btn:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}

.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.page-numbers {
display: flex;
gap: 5px;
}

.page-num {
width: 40px;
height: 40px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}

.page-num:hover:not(.active) {
border-color: #667eea;
color: #667eea;
}

.page-num.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}

.page-info {
color: #666;
margin-left: 20px;
}
</style>

9. components/SearchBar.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
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
<template>
<div class="search-bar">
<input
:value="value"
@input="$emit('update:value', $event.target.value)"
type="text"
:placeholder="placeholder"
class="search-input"
/>
<span class="search-icon">🔍</span>
</div>
</template>

<script setup>
defineProps({
value: String,
placeholder: {
type: String,
default: '搜索...'
}
})

defineEmits(['update:value'])
</script>

<style scoped>
.search-bar {
position: relative;
flex: 1;
max-width: 400px;
}

.search-input {
width: 100%;
padding: 12px 45px 12px 15px;
border: 2px solid #e0e0e0;
border-radius: 25px;
font-size: 1rem;
transition: all 0.3s;
}

.search-input:focus {
outline: none;
border-color: #667eea;
}

.search-icon {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
opacity: 0.5;
}
</style>

10. components/Modal.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
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
<template>
<Teleport to="body">
<transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="close">
<div class="modal-content">
<div class="modal-header">
<h3>{{ title }}</h3>
<button @click="close" class="close-btn">×</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
</div>
</div>
</transition>
</Teleport>
</template>

<script setup>
defineProps({
show: Boolean,
title: String
})

const emit = defineEmits(['close'])

const close = () => {
emit('close')
}
</script>

<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}

.modal-content {
background: white;
border-radius: 15px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}

.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 25px;
border-bottom: 1px solid #eee;
}

.modal-header h3 {
margin: 0;
font-size: 1.3rem;
color: #333;
}

.close-btn {
width: 35px;
height: 35px;
border: none;
background: #f8f9fa;
border-radius: 50%;
cursor: pointer;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}

.close-btn:hover {
background: #e9ecef;
transform: rotate(90deg);
}

.modal-body {
padding: 20px;
}

/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}

.modal-enter-from,
.modal-leave-to {
opacity: 0;
}

.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: scale(0.9);
}
</style>

项目总结

本用户管理系统展示了:

  1. 企业级架构:分层清晰的目录结构
  2. 状态管理:使用 Pinia 管理复杂状态
  3. 表单处理:完整的验证逻辑
  4. API 封装:统一的请求处理
  5. 组件复用:高度可复用的组件设计

项目三:电商后台管理系统

项目简介

一个功能完整的电商后台管理系统,包含数据可视化、商品管理、订单管理、用户管理等核心模块。

功能特性

  • ✅ 数据可视化大屏(ECharts)
  • ✅ 商品管理(增删改查)
  • ✅ 订单管理
  • ✅ 用户管理
  • ✅ 数据统计图表
  • ✅ 文件上传
  • ✅ 权限管理

项目结构

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
ecommerce-admin/
├── public/
│ └── index.html
├── src/
│ ├── api/
│ │ ├── product.js
│ │ ├── order.js
│ │ └── statistics.js
│ ├── components/
│ │ ├── charts/
│ │ │ ├── SalesChart.vue
│ │ │ ├── CategoryChart.vue
│ │ │ └── TrendChart.vue
│ │ ├── Layout.vue
│ │ ├── Sidebar.vue
│ │ └── Header.vue
│ ├── router/
│ │ └── index.js
│ ├── stores/
│ │ ├── productStore.js
│ │ └── orderStore.js
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── Products.vue
│ │ ├── Orders.vue
│ │ └── Users.vue
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js

核心代码实现

1. main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import './assets/styles/main.css'

const app = createApp(App)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}

app.use(createPinia())
app.use(router)
app.use(ElementPlus)

app.mount('#app')

2. router/index.js

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
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '../components/Layout.vue'

const routes = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: '数据概览', icon: 'DataAnalysis' }
},
{
path: 'products',
name: 'Products',
component: () => import('../views/Products.vue'),
meta: { title: '商品管理', icon: 'Goods' }
},
{
path: 'orders',
name: 'Orders',
component: () => import('../views/Orders.vue'),
meta: { title: '订单管理', icon: 'Document' }
},
{
path: 'users',
name: 'Users',
component: () => import('../views/Users.vue'),
meta: { title: '用户管理', icon: 'User' }
}
]
}
]

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

export default router

3. views/Dashboard.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
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
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stats-cards">
<div
v-for="stat in stats"
:key="stat.title"
class="stat-card"
:style="{ background: stat.color }"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<h3>{{ stat.value }}</h3>
<p>{{ stat.title }}</p>
</div>
<div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'">
{{ stat.trend > 0 ? '↑' : '↓' }} {{ Math.abs(stat.trend) }}%
</div>
</div>
</div>

<!-- 图表区域 -->
<div class="charts-grid">
<div class="chart-container">
<h3>📊 销售趋势</h3>
<SalesChart />
</div>

<div class="chart-container">
<h3>🏷️ 商品分类占比</h3>
<CategoryChart />
</div>

<div class="chart-container full-width">
<h3>📈 订单趋势分析</h3>
<TrendChart />
</div>
</div>

<!-- 最新订单 -->
<div class="recent-orders">
<h3>🛒 最新订单</h3>
<el-table :data="recentOrders" style="width: 100%">
<el-table-column prop="orderId" label="订单号" width="180" />
<el-table-column prop="customer" label="客户" />
<el-table-column prop="amount" label="金额">
<template #default="{ row }">
¥{{ row.amount.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" />
</el-table>
</div>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import SalesChart from '../components/charts/SalesChart.vue'
import CategoryChart from '../components/charts/CategoryChart.vue'
import TrendChart from '../components/charts/TrendChart.vue'

const stats = ref([
{
title: '今日销售额',
value: '¥125,680',
icon: '💰',
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
trend: 12.5
},
{
title: '订单数量',
value: '1,234',
icon: '📦',
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
trend: -3.2
},
{
title: '新增用户',
value: '856',
icon: '👥',
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
trend: 8.7
},
{
title: '商品总数',
value: '3,456',
icon: '🏷️',
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
trend: 5.1
}
])

const recentOrders = ref([])

const getStatusType = (status) => {
const types = {
'待付款': 'warning',
'待发货': 'primary',
'已发货': 'info',
'已完成': 'success',
'已取消': 'danger'
}
return types[status] || 'info'
}

onMounted(async () => {
// 加载最新订单数据
recentOrders.value = [
{
orderId: 'ORD202410090001',
customer: '张三',
amount: 299.00,
status: '待发货',
createdAt: '2024-10-09 10:30'
},
{
orderId: 'ORD202410090002',
customer: '李四',
amount: 1299.00,
status: '已完成',
createdAt: '2024-10-09 09:15'
},
{
orderId: 'ORD202410090003',
customer: '王五',
amount: 599.00,
status: '待付款',
createdAt: '2024-10-09 08:45'
}
]
})
</script>

<style scoped>
.dashboard {
padding: 30px;
}

.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}

.stat-card {
padding: 25px;
border-radius: 15px;
color: white;
display: flex;
align-items: center;
gap: 15px;
position: relative;
overflow: hidden;
}

.stat-icon {
font-size: 3rem;
opacity: 0.8;
}

.stat-info h3 {
font-size: 2rem;
margin: 0 0 5px 0;
}

.stat-info p {
margin: 0;
opacity: 0.9;
font-size: 0.95rem;
}

.stat-trend {
position: absolute;
top: 15px;
right: 15px;
padding: 5px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.2);
font-size: 0.85rem;
font-weight: 600;
}

.stat-trend.up {
color: #4ade80;
}

.stat-trend.down {
color: #f87171;
}

.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 30px;
}

.chart-container {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}

.chart-container.full-width {
grid-column: span 2;
}

.chart-container h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.1rem;
}

.recent-orders {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}

.recent-orders h3 {
margin: 0 0 20px 0;
color: #333;
}
</style>

4. components/charts/SalesChart.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
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
<template>
<div ref="chartRef" class="chart"></div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'

const chartRef = ref(null)
let chartInstance = null

const initChart = () => {
if (!chartRef.value) return

chartInstance = echarts.init(chartRef.value)

const option = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(50, 50, 50, 0.9)',
borderColor: '#333',
textStyle: { color: '#fff' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
axisLine: { lineStyle: { color: '#e0e0e0' } },
axisLabel: { color: '#666' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f0f0f0' } },
axisLabel: {
color: '#666',
formatter: '¥{value}'
}
},
series: [{
name: '销售额',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#667eea' },
{ offset: 1, color: '#764ba2' }
])
},
itemStyle: {
color: '#667eea',
borderWidth: 3,
borderColor: '#fff'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(102, 126, 234, 0.3)' },
{ offset: 1, color: 'rgba(102, 126, 234, 0.05)' }
])
},
data: [8200, 9320, 9010, 9340, 12900, 13300, 13200]
}]
}

chartInstance.setOption(option)
}

const handleResize = () => {
chartInstance?.resize()
}

onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>

<style scoped>
.chart {
width: 100%;
height: 300px;
}
</style>

5. components/Layout.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
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
<template>
<div class="layout">
<Sidebar :collapsed="sidebarCollapsed" @toggle="toggleSidebar" />

<div class="main-container" :class="{ expanded: sidebarCollapsed }">
<Header
:collapsed="sidebarCollapsed"
@toggle-sidebar="toggleSidebar"
/>

<main class="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue'
import Sidebar from './Sidebar.vue'
import Header from './Header.vue'

const sidebarCollapsed = ref(false)

const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script>

<style scoped>
.layout {
display: flex;
min-height: 100vh;
background: #f5f7fa;
}

.main-container {
flex: 1;
margin-left: 250px;
transition: margin-left 0.3s ease;
display: flex;
flex-direction: column;
}

.main-container.expanded {
margin-left: 80px;
}

.content {
flex: 1;
overflow-y: auto;
}

.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

6. views/Products.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
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
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
<template>
<div class="products-page">
<div class="page-header">
<h2>商品管理</h2>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加商品
</el-button>
</div>

<!-- 筛选栏 -->
<div class="filter-bar">
<el-input
v-model="searchQuery"
placeholder="搜索商品名称"
style="width: 300px"
clearable
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>

<el-select v-model="categoryFilter" placeholder="商品分类" clearable>
<el-option label="电子产品" value="电子产品" />
<el-option label="服装鞋帽" value="服装鞋帽" />
<el-option label="食品饮料" value="食品饮料" />
<el-option label="家居用品" value="家居用品" />
</el-select>

<el-select v-model="statusFilter" placeholder="商品状态" clearable>
<el-option label="上架" value="active" />
<el-option label="下架" value="inactive" />
</el-select>
</div>

<!-- 商品表格 -->
<el-table :data="filteredProducts" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />

<el-table-column label="商品信息" min-width="300">
<template #default="{ row }">
<div class="product-info">
<img :src="row.image" :alt="row.name" class="product-image" />
<div>
<div class="product-name">{{ row.name }}</div>
<div class="product-desc">{{ row.description }}</div>
</div>
</div>
</template>
</el-table-column>

<el-table-column prop="category" label="分类" width="120" />

<el-table-column prop="price" label="价格" width="120">
<template #default="{ row }">
¥{{ row.price.toFixed(2) }}
</template>
</el-table-column>

<el-table-column prop="stock" label="库存" width="100">
<template #default="{ row }">
<el-tag :type="row.stock < 10 ? 'danger' : 'success'">
{{ row.stock }}
</el-tag>
</template>
</el-table-column>

<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
active-value="active"
inactive-value="inactive"
active-text="上架"
inactive-text="下架"
/>
</template>
</el-table-column>

<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editProduct(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteProduct(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>

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

<!-- 添加/编辑对话框 -->
<el-dialog
v-model="showAddDialog"
:title="editingProduct ? '编辑商品' : '添加商品'"
width="600px"
>
<el-form :model="productForm" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="商品名称" prop="name">
<el-input v-model="productForm.name" placeholder="请输入商品名称" />
</el-form-item>

<el-form-item label="商品分类" prop="category">
<el-select v-model="productForm.category" placeholder="请选择分类">
<el-option label="电子产品" value="电子产品" />
<el-option label="服装鞋帽" value="服装鞋帽" />
<el-option label="食品饮料" value="食品饮料" />
<el-option label="家居用品" value="家居用品" />
</el-select>
</el-form-item>

<el-form-item label="商品价格" prop="price">
<el-input-number
v-model="productForm.price"
:min="0"
:precision="2"
placeholder="请输入价格"
/>
</el-form-item>

<el-form-item label="库存数量" prop="stock">
<el-input-number
v-model="productForm.stock"
:min="0"
placeholder="请输入库存"
/>
</el-form-item>

<el-form-item label="商品描述" prop="description">
<el-input
v-model="productForm.description"
type="textarea"
:rows="3"
placeholder="请输入商品描述"
/>
</el-form-item>

<el-form-item label="商品图片" prop="image">
<el-upload
class="image-uploader"
:show-file-list="false"
:on-success="handleImageSuccess"
action="/api/upload"
>
<img v-if="productForm.image" :src="productForm.image" class="uploaded-image" />
<el-icon v-else class="upload-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>

<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>

<script setup>
import { ref, computed, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

const loading = ref(false)
const searchQuery = ref('')
const categoryFilter = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

const showAddDialog = ref(false)
const editingProduct = ref(null)
const formRef = ref(null)

const products = ref([
{
id: 1,
name: 'iPhone 15 Pro Max',
category: '电子产品',
price: 9999,
stock: 50,
status: 'active',
description: '最新款 iPhone,A17 Pro 芯片',
image: 'https://via.placeholder.com/100'
},
{
id: 2,
name: 'Nike Air Max',
category: '服装鞋帽',
price: 1299,
stock: 5,
status: 'active',
description: '经典运动鞋款',
image: 'https://via.placeholder.com/100'
}
])

const productForm = reactive({
name: '',
category: '',
price: 0,
stock: 0,
description: '',
image: '',
status: 'active'
})

const rules = {
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择分类', trigger: 'change' }],
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
stock: [{ required: true, message: '请输入库存', trigger: 'blur' }]
}

const filteredProducts = computed(() => {
let result = products.value

if (searchQuery.value) {
result = result.filter(p =>
p.name.includes(searchQuery.value) ||
p.description.includes(searchQuery.value)
)
}

if (categoryFilter.value) {
result = result.filter(p => p.category === categoryFilter.value)
}

if (statusFilter.value) {
result = result.filter(p => p.status === statusFilter.value)
}

return result
})

const editProduct = (product) => {
editingProduct.value = product
Object.assign(productForm, product)
showAddDialog.value = true
}

const deleteProduct = async (id) => {
try {
await ElMessageBox.confirm('确定要删除此商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})

products.value = products.value.filter(p => p.id !== id)
ElMessage.success('删除成功')
} catch {
// 用户取消
}
}

const handleImageSuccess = (response) => {
productForm.image = response.url
}

const submitForm = async () => {
try {
await formRef.value.validate()

if (editingProduct.value) {
// 编辑
const index = products.value.findIndex(p => p.id === editingProduct.value.id)
if (index !== -1) {
products.value[index] = { ...editingProduct.value, ...productForm }
}
ElMessage.success('更新成功')
} else {
// 添加
const newProduct = {
id: Date.now(),
...productForm
}
products.value.unshift(newProduct)
ElMessage.success('添加成功')
}

showAddDialog.value = false
editingProduct.value = null
formRef.value.resetFields()
} catch {
// 验证失败
}
}

const handleSizeChange = (size) => {
pageSize.value = size
}

const handleCurrentChange = (page) => {
currentPage.value = page
}
</script>

<style scoped>
.products-page {
padding: 30px;
}

.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}

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

.product-info {
display: flex;
align-items: center;
gap: 15px;
}

.product-image {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}

.product-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}

.product-desc {
font-size: 0.85rem;
color: #999;
}

.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 30px;
}

.image-uploader {
width: 120px;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}

.image-uploader:hover {
border-color: #667eea;
}

.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}

.upload-icon {
font-size: 28px;
color: #8c939d;
}
</style>

项目总结

电商后台管理系统展示了:

  1. 复杂业务场景:多模块、多功能集成
  2. 数据可视化:ECharts 图表集成
  3. UI 组件库:Element Plus 实战应用
  4. 权限控制:基于角色的访问控制
  5. 文件上传:图片上传与管理
  6. 复杂表格:筛选、排序、分页

本章小结

通过本章的学习,我们完成了三个不同复杂度的 Vue.js 实战项目:

  1. Todo List:掌握基础的组件化开发、状态管理、本地存储
  2. 用户管理系统:学习企业级应用架构、表单验证、API 集成
  3. 电商后台:实战复杂业务场景、数据可视化、权限管理

这些项目涵盖了 Vue.js 开发的核心技能点,为实际工作打下了坚实基础。

下一章我们将继续学习更高级的项目实战案例,包括实时聊天应用、数据可视化大屏、低代码平台等内容。