Vue.js 项目实战案例进阶

本章将深入探索四个高级 Vue.js 实战项目,涵盖实时通信、数据可视化、低代码平台和内容管理系统等前沿技术领域。

目录

  1. 项目四:实时聊天应用
  2. 项目五:数据可视化大屏
  3. 项目六:低代码表单引擎
  4. 项目七:博客 CMS 系统

项目四:实时聊天应用

项目简介

一个功能完整的实时聊天应用,支持私聊、群聊、消息推送、在线状态等功能,使用 WebSocket 和 Socket.io 技术实现实时通信。

功能特性

  • ✅ 实时消息收发
  • ✅ 私聊和群聊
  • ✅ 在线状态显示
  • ✅ 消息已读状态
  • ✅ 历史消息查询
  • ✅ 文件/图片发送
  • ✅ 消息通知
  • ✅ 用户搜索添加

架构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
实时聊天应用架构
┌─────────────────────────────────────────────────────────┐
│ 前端 (Vue 3) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 聊天界面 │ │ 联系人列表│ │ 消息列表 │ │ 用户管理 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
↕ WebSocket
┌─────────────────────────────────────────────────────────┐
│ Socket.io Server (Node.js) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 连接管理 │ │ 消息路由 │ │ 房间管理 │ │ 认证授权 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MongoDB │ │ Redis │ │ 消息队列 │ │ 文件存储 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘

项目结构

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
realtime-chat/
├── client/ # 前端项目
│ ├── src/
│ │ ├── components/
│ │ │ ├── ChatRoom.vue
│ │ │ ├── MessageList.vue
│ │ │ ├── MessageInput.vue
│ │ │ ├── ContactList.vue
│ │ │ └── UserStatus.vue
│ │ ├── composables/
│ │ │ ├── useSocket.js
│ │ │ ├── useChat.js
│ │ │ └── useNotification.js
│ │ ├── stores/
│ │ │ ├── chatStore.js
│ │ │ └── userStore.js
│ │ ├── views/
│ │ │ ├── Chat.vue
│ │ │ ├── Login.vue
│ │ │ └── Register.vue
│ │ ├── router/
│ │ │ └── index.js
│ │ ├── App.vue
│ │ └── main.js
│ └── package.json
├── server/ # 后端项目
│ ├── src/
│ │ ├── handlers/
│ │ │ ├── connection.js
│ │ │ ├── message.js
│ │ │ └── room.js
│ │ ├── models/
│ │ │ ├── User.js
│ │ │ ├── Message.js
│ │ │ └── Room.js
│ │ ├── middleware/
│ │ │ └── auth.js
│ │ └── app.js
│ └── package.json
└── docker-compose.yml

完整代码实现

1. 客户端 - main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
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.use(ElementPlus)

app.mount('#app')

2. composables/useSocket.js - WebSocket 管理

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
import { ref, onMounted, onUnmounted } from 'vue'
import { io } from 'socket.io-client'
import { useUserStore } from '../stores/userStore'
import { useChatStore } from '../stores/chatStore'

export function useSocket() {
const socket = ref(null)
const connected = ref(false)
const userStore = useUserStore()
const chatStore = useChatStore()

const connect = () => {
const token = localStorage.getItem('token')
if (!token) return

socket.value = io('http://localhost:3000', {
auth: { token },
transports: ['websocket']
})

// 连接事件
socket.value.on('connect', () => {
connected.value = true
console.log('WebSocket 连接成功')
})

socket.value.on('disconnect', () => {
connected.value = false
console.log('WebSocket 断开连接')
})

// 消息事件
socket.value.on('message', (message) => {
chatStore.addMessage(message)
showNotification(message)
})

// 私聊消息
socket.value.on('private-message', (message) => {
chatStore.addPrivateMessage(message)
showNotification(message)
})

// 用户上线/离线
socket.value.on('user-online', (userId) => {
userStore.updateUserStatus(userId, 'online')
})

socket.value.on('user-offline', (userId) => {
userStore.updateUserStatus(userId, 'offline')
})

// 已读回执
socket.value.on('message-read', ({ messageId, userId }) => {
chatStore.markMessageAsRead(messageId, userId)
})

// 正在输入
socket.value.on('typing', ({ userId, roomId }) => {
chatStore.setUserTyping(userId, roomId, true)
setTimeout(() => {
chatStore.setUserTyping(userId, roomId, false)
}, 3000)
})
}

const disconnect = () => {
if (socket.value) {
socket.value.disconnect()
socket.value = null
}
}

const sendMessage = (data) => {
if (!socket.value || !connected.value) {
console.error('WebSocket 未连接')
return false
}

socket.value.emit('send-message', data)
return true
}

const sendPrivateMessage = (data) => {
if (!socket.value || !connected.value) return false

socket.value.emit('send-private-message', data)
return true
}

const joinRoom = (roomId) => {
socket.value?.emit('join-room', roomId)
}

const leaveRoom = (roomId) => {
socket.value?.emit('leave-room', roomId)
}

const markAsRead = (messageId) => {
socket.value?.emit('mark-read', messageId)
}

const sendTyping = (roomId) => {
socket.value?.emit('typing', roomId)
}

const showNotification = (message) => {
if (Notification.permission === 'granted') {
new Notification('新消息', {
body: `${message.sender.name}: ${message.content}`,
icon: message.sender.avatar
})
}
}

onMounted(() => {
connect()
})

onUnmounted(() => {
disconnect()
})

return {
socket,
connected,
connect,
disconnect,
sendMessage,
sendPrivateMessage,
joinRoom,
leaveRoom,
markAsRead,
sendTyping
}
}

3. stores/chatStore.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
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
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useChatStore = defineStore('chat', () => {
// 状态
const messages = ref([])
const privateMessages = ref({})
const currentRoom = ref(null)
const currentPrivateChat = ref(null)
const onlineUsers = ref(new Set())
const typingUsers = ref({})

// 计算属性
const currentMessages = computed(() => {
if (currentPrivateChat.value) {
return privateMessages.value[currentPrivateChat.value] || []
}
return messages.value.filter(m => m.roomId === currentRoom.value)
})

const unreadCount = computed(() => {
return Object.values(privateMessages.value)
.flat()
.filter(m => !m.read).length
})

// 方法
const addMessage = (message) => {
messages.value.push({
...message,
timestamp: new Date().toISOString(),
read: false
})
}

const addPrivateMessage = (message) => {
const userId = message.sender.id === message.recipient.id
? message.recipient.id
: message.sender.id

if (!privateMessages.value[userId]) {
privateMessages.value[userId] = []
}

privateMessages.value[userId].push({
...message,
timestamp: new Date().toISOString(),
read: false
})
}

const setCurrentRoom = (roomId) => {
currentRoom.value = roomId
currentPrivateChat.value = null
}

const setCurrentPrivateChat = (userId) => {
currentPrivateChat.value = userId
currentRoom.value = null
}

const markMessageAsRead = (messageId, userId) => {
const msgList = privateMessages.value[userId]
if (msgList) {
const msg = msgList.find(m => m.id === messageId)
if (msg) {
msg.read = true
}
}
}

const setUserTyping = (userId, roomId, isTyping) => {
if (!typingUsers.value[roomId]) {
typingUsers.value[roomId] = new Set()
}

if (isTyping) {
typingUsers.value[roomId].add(userId)
} else {
typingUsers.value[roomId].delete(userId)
}
}

const clearMessages = () => {
messages.value = []
privateMessages.value = {}
}

return {
messages,
privateMessages,
currentRoom,
currentPrivateChat,
onlineUsers,
typingUsers,
currentMessages,
unreadCount,
addMessage,
addPrivateMessage,
setCurrentRoom,
setCurrentPrivateChat,
markMessageAsRead,
setUserTyping,
clearMessages
}
})

4. components/ChatRoom.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
<template>
<div class="chat-room">
<!-- 聊天头部 -->
<div class="chat-header">
<div class="chat-info">
<h3>{{ currentChatName }}</h3>
<span class="status" :class="{ online: isOnline }">
{{ isOnline ? '在线' : '离线' }}
</span>
</div>

<div class="chat-actions">
<el-button @click="showInfo = true" circle>
<el-icon><InfoFilled /></el-icon>
</el-button>

<el-button @click="clearChat" circle>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>

<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<div v-if="loading" class="loading">
<el-icon class="is-loading"><Loading /></el-icon>
加载中...
</div>

<div v-else-if="currentMessages.length === 0" class="empty-state">
<p>开始聊天吧 👋</p>
</div>

<MessageItem
v-else
v-for="message in currentMessages"
:key="message.id"
:message="message"
:isOwn="message.sender.id === currentUserId"
/>

<!-- 正在输入提示 -->
<div v-if="typingUsersList.length > 0" class="typing-indicator">
<span v-for="(user, index) in typingUsersList" :key="user.id">
{{ user.name }}{{ index < typingUsersList.length - 1 ? '、' : '' }}
</span>
正在输入...
</div>
</div>

<!-- 消息输入 -->
<MessageInput
@send="handleSend"
@typing="handleTyping"
@upload="handleUpload"
/>
</div>
</template>

<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chatStore'
import { useUserStore } from '../stores/userStore'
import { useSocket } from '../composables/useSocket'
import MessageItem from './MessageItem.vue'
import MessageInput from './MessageInput.vue'

const chatStore = useChatStore()
const userStore = useUserStore()
const { sendMessage, sendPrivateMessage, sendTyping } = useSocket()

const messageListRef = ref(null)
const showInfo = ref(false)
const loading = ref(false)

const currentUserId = computed(() => userStore.currentUser?.id)
const currentMessages = computed(() => chatStore.currentMessages)
const currentChatName = computed(() => {
if (chatStore.currentRoom) {
return chatStore.currentRoom.name
} else if (chatStore.currentPrivateChat) {
const user = userStore.users.find(u => u.id === chatStore.currentPrivateChat)
return user?.name || '私聊'
}
return '聊天室'
})

const isOnline = computed(() => {
if (chatStore.currentPrivateChat) {
return chatStore.onlineUsers.has(chatStore.currentPrivateChat)
}
return true
})

const typingUsersList = computed(() => {
if (!chatStore.currentRoom) return []

const typingSet = chatStore.typingUsers[chatStore.currentRoom.id]
if (!typingSet) return []

return Array.from(typingSet)
.map(userId => userStore.users.find(u => u.id === userId))
.filter(Boolean)
})

// 自动滚动到底部
watch(currentMessages, () => {
nextTick(() => {
scrollToBottom()
})
}, { deep: true })

const scrollToBottom = () => {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
}

const handleSend = (content, type = 'text') => {
const messageData = {
content,
type,
sender: {
id: currentUserId.value,
name: userStore.currentUser.name,
avatar: userStore.currentUser.avatar
}
}

if (chatStore.currentPrivateChat) {
messageData.recipient = chatStore.currentPrivateChat
sendPrivateMessage(messageData)
} else {
messageData.roomId = chatStore.currentRoom.id
sendMessage(messageData)
}
}

const handleTyping = () => {
if (chatStore.currentRoom) {
sendTyping(chatStore.currentRoom.id)
}
}

const handleUpload = async (file) => {
// 处理文件上传
const formData = new FormData()
formData.append('file', file)

try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const data = await response.json()

handleSend(data.url, file.type.startsWith('image') ? 'image' : 'file')
} catch (error) {
console.error('文件上传失败:', error)
}
}

const clearChat = () => {
chatStore.clearMessages()
}
</script>

<style scoped>
.chat-room {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f7fa;
}

.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: white;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}

.chat-info h3 {
margin: 0;
font-size: 1.2rem;
color: #303133;
}

.status {
font-size: 0.85rem;
color: #909399;
margin-left: 10px;
}

.status.online {
color: #67c23a;
}

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

.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 15px;
}

.loading,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #909399;
font-size: 1rem;
}

.typing-indicator {
padding: 10px;
font-size: 0.85rem;
color: #909399;
font-style: italic;
}
</style>

5. components/MessageInput.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
<template>
<div class="message-input">
<!-- 工具栏 -->
<div class="toolbar">
<el-button @click="toggleEmoji" circle size="small">
😀
</el-button>

<el-upload
:show-file-list="false"
:before-upload="handleUpload"
accept="image/*,video/*,.pdf,.doc,.docx"
>
<el-button circle size="small">
📎
</el-button>
</el-upload>
</div>

<!-- 输入区域 -->
<div class="input-wrapper">
<textarea
v-model="messageText"
@keydown.enter.exact.prevent="handleSend"
@input="handleTyping"
placeholder="输入消息... (Enter 发送)"
rows="3"
ref="textareaRef"
></textarea>

<el-button
type="primary"
@click="handleSend"
:disabled="!messageText.trim()"
>
发送
</el-button>
</div>

<!-- 表情选择器 -->
<div v-if="showEmoji" class="emoji-picker">
<span
v-for="emoji in emojis"
:key="emoji"
@click="insertEmoji(emoji)"
class="emoji-item"
>
{{ emoji }}
</span>
</div>
</div>
</template>

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

const emit = defineEmits(['send', 'typing', 'upload'])

const messageText = ref('')
const showEmoji = ref(false)
const textareaRef = ref(null)

const emojis = [
'😀', '😂', '🤣', '😊', '😍', '🥰', '😘', '😜',
'🤔', '😎', '👍', '👎', '👏', '🙌', '❤️', '💔',
'🎉', '🔥', '💪', '🙏', '👏', '😊', '🙄', '😤'
]

const handleSend = () => {
const text = messageText.value.trim()
if (text) {
emit('send', text)
messageText.value = ''
}
}

const handleTyping = () => {
emit('typing')
}

const handleUpload = (file) => {
emit('upload', file)
return false // 阻止自动上传
}

const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
}

const insertEmoji = (emoji) => {
messageText.value += emoji
showEmoji.value = false
textareaRef.value?.focus()
}
</script>

<style scoped>
.message-input {
background: white;
border-top: 1px solid #e4e7ed;
padding: 15px;
}

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

.input-wrapper {
display: flex;
gap: 10px;
}

textarea {
flex: 1;
padding: 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
resize: none;
font-size: 14px;
line-height: 1.5;
transition: border-color 0.3s;
}

textarea:focus {
outline: none;
border-color: #409eff;
}

.emoji-picker {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 10px;
background: white;
border-radius: 4px;
margin-top: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.emoji-item {
font-size: 20px;
cursor: pointer;
padding: 5px;
transition: transform 0.2s;
}

.emoji-item:hover {
transform: scale(1.2);
}
</style>

6. 服务端 - app.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
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
const express = require('express')
const { createServer } = require('http')
const { Server } = require('socket.io')
const mongoose = require('mongoose')
const redis = require('redis')
const jwt = require('jsonwebtoken')
const cors = require('cors')

const app = express()
const httpServer = createServer(app)
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:5173',
methods: ['GET', 'POST']
}
})

// 中间件
app.use(cors())
app.use(express.json())

// Redis 客户端
const redisClient = redis.createClient()

// MongoDB 连接
mongoose.connect('mongodb://localhost:27017/chatapp')

// 用户模型
const User = mongoose.model('User', new mongoose.Schema({
username: String,
email: String,
password: String,
avatar: String,
status: { type: String, default: 'offline' },
lastSeen: Date
}))

// 消息模型
const Message = mongoose.model('Message', new mongoose.Schema({
content: String,
type: { type: String, default: 'text' },
sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
recipient: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
roomId: String,
read: { type: Boolean, default: false },
timestamp: { type: Date, default: Date.now }
}))

// 认证中间件
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token
const decoded = jwt.verify(token, process.env.JWT_SECRET)
socket.userId = decoded.userId
next()
} catch (error) {
next(new Error('Authentication error'))
}
})

// 连接处理
io.on('connection', async (socket) => {
const userId = socket.userId

console.log(`用户 ${userId} 已连接`)

// 更新用户在线状态
await User.findByIdAndUpdate(userId, { status: 'online' })
await redisClient.set(`user:${userId}`, socket.id)
io.emit('user-online', userId)

// 加入房间
socket.on('join-room', async (roomId) => {
socket.join(roomId)
console.log(`用户 ${userId} 加入房间 ${roomId}`)
})

// 离开房间
socket.on('leave-room', (roomId) => {
socket.leave(roomId)
})

// 发送消息
socket.on('send-message', async (data) => {
const message = new Message({
content: data.content,
type: data.type || 'text',
sender: userId,
roomId: data.roomId
})

await message.save()

const populatedMessage = await Message.findById(message._id)
.populate('sender', 'username avatar')

io.to(data.roomId).emit('message', populatedMessage)
})

// 私聊消息
socket.on('send-private-message', async (data) => {
const message = new Message({
content: data.content,
type: data.type || 'text',
sender: userId,
recipient: data.recipient
})

await message.save()

const populatedMessage = await Message.findById(message._id)
.populate('sender', 'username avatar')

// 发送给接收者
const recipientSocketId = await redisClient.get(`user:${data.recipient}`)
if (recipientSocketId) {
io.to(recipientSocketId).emit('private-message', populatedMessage)
}

// 发送给自己
socket.emit('private-message', populatedMessage)
})

// 正在输入
socket.on('typing', (roomId) => {
socket.to(roomId).emit('typing', { userId, roomId })
})

// 标记已读
socket.on('mark-read', async (messageId) => {
await Message.findByIdAndUpdate(messageId, { read: true })
})

// 断开连接
socket.on('disconnect', async () => {
console.log(`用户 ${userId} 已断开连接`)

await User.findByIdAndUpdate(userId, {
status: 'offline',
lastSeen: new Date()
})

await redisClient.del(`user:${userId}`)
io.emit('user-offline', userId)
})
})

// REST API
app.get('/api/messages/:roomId', async (req, res) => {
try {
const messages = await Message.find({ roomId: req.params.roomId })
.populate('sender', 'username avatar')
.sort({ timestamp: -1 })
.limit(50)

res.json(messages)
} catch (error) {
res.status(500).json({ error: error.message })
}
})

app.get('/api/users', async (req, res) => {
try {
const users = await User.find({}, 'username avatar status lastSeen')
res.json(users)
} catch (error) {
res.status(500).json({ error: error.message })
}
})

// 启动服务器
const PORT = process.env.PORT || 3000
httpServer.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT}`)
})

部署指南

1. Docker 部署

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
# docker-compose.yml
version: '3.8'

services:
client:
build: ./client
ports:
- "80:80"
depends_on:
- server
environment:
- VITE_API_URL=http://server:3000

server:
build: ./server
ports:
- "3000:3000"
depends_on:
- mongodb
- redis
environment:
- MONGODB_URI=mongodb://mongodb:27017/chatapp
- REDIS_URL=redis://redis:6379
- JWT_SECRET=your-secret-key

mongodb:
image: mongo:6
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db

redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data

volumes:
mongodb_data:
redis_data:

2. 性能优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// socket.io 配置优化
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
methods: ['GET', 'POST']
},
// 性能优化配置
pingTimeout: 60000,
pingInterval: 25000,
upgradeTimeout: 30000,
maxHttpBufferSize: 1e7, // 10MB
// 负载均衡支持
transports: ['websocket', 'polling']
})

// 使用 Redis Adapter 实现集群
const { createAdapter } = require('@socket.io/redis-adapter')
const pubClient = redis.createClient({ url: process.env.REDIS_URL })
const subClient = pubClient.duplicate()

io.adapter(createAdapter(pubClient, subClient))

最佳实践

  1. 连接管理

    • 实现心跳检测
    • 处理断线重连
    • 限制连接频率
  2. 消息可靠性

    • 消息确认机制
    • 离线消息存储
    • 消息同步策略
  3. 安全性

    • JWT 认证
    • 消息加密
    • 频率限制

项目五:数据可视化大屏

项目简介

一个企业级数据可视化大屏系统,支持实时数据更新、多种图表类型、响应式布局,使用 ECharts 和 Vue 3 构建。

功能特性

  • ✅ 多种图表类型(折线图、柱状图、饼图、雷达图等)
  • ✅ 实时数据更新
  • ✅ 响应式布局
  • ✅ 数据刷新动画
  • ✅ 大屏自适应
  • ✅ 数据导出
  • ✅ 主题切换

项目结构

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
data-visualization/
├── public/
│ └── index.html
├── src/
│ ├── components/
│ │ ├── charts/
│ │ │ ├── LineChart.vue
│ │ │ ├── BarChart.vue
│ │ │ ├── PieChart.vue
│ │ │ ├── RadarChart.vue
│ │ │ └── GaugeChart.vue
│ │ ├── Dashboard.vue
│ │ ├── DataCard.vue
│ │ └── ControlPanel.vue
│ ├── composables/
│ │ ├── useChart.js
│ │ ├── useDataRefresh.js
│ │ └── useScreen.js
│ ├── api/
│ │ └── statistics.js
│ ├── utils/
│ │ └── chartTheme.js
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js

核心代码实现

1. composables/useChart.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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'

export function useChart(chartRef, theme = 'default') {
const chart = ref(null)
const loading = ref(false)

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

chart.value = echarts.init(chartRef.value, theme)

// 响应式调整
const resizeObserver = new ResizeObserver(() => {
chart.value?.resize()
})

resizeObserver.observe(chartRef.value)

return () => {
resizeObserver.disconnect()
}
}

const setOption = (option, notMerge = false, lazyUpdate = false) => {
if (!chart.value) return

chart.value.setOption(option, notMerge, lazyUpdate)
}

const showLoading = () => {
loading.value = true
chart.value?.showLoading({
text: '数据加载中...',
color: '#409eff',
textColor: '#409eff',
maskColor: 'rgba(255, 255, 255, 0.8)'
})
}

const hideLoading = () => {
loading.value = false
chart.value?.hideLoading()
}

const resize = () => {
chart.value?.resize()
}

const clear = () => {
chart.value?.clear()
}

const dispose = () => {
chart.value?.dispose()
chart.value = null
}

const downloadImage = (fileName = 'chart') => {
if (!chart.value) return

const url = chart.value.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff'
})

const link = document.createElement('a')
link.href = url
link.download = `${fileName}.png`
link.click()
}

onMounted(() => {
nextTick(() => {
initChart()
})
})

onUnmounted(() => {
dispose()
})

return {
chart,
loading,
setOption,
showLoading,
hideLoading,
resize,
clear,
dispose,
downloadImage
}
}

2. composables/useDataRefresh.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
import { ref, onMounted, onUnmounted } from 'vue'

export function useDataRefresh(callback, interval = 5000) {
const timer = ref(null)
const isRefreshing = ref(false)
const refreshCount = ref(0)

const startRefresh = () => {
if (timer.value) return

isRefreshing.value = true
timer.value = setInterval(async () => {
try {
await callback()
refreshCount.value++
} catch (error) {
console.error('数据刷新失败:', error)
stopRefresh()
}
}, interval)
}

const stopRefresh = () => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
isRefreshing.value = false
}

const toggleRefresh = () => {
if (isRefreshing.value) {
stopRefresh()
} else {
startRefresh()
}
}

const setRefreshInterval = (newInterval) => {
stopRefresh()
interval = newInterval
if (isRefreshing.value) {
startRefresh()
}
}

onMounted(() => {
startRefresh()
})

onUnmounted(() => {
stopRefresh()
})

return {
isRefreshing,
refreshCount,
startRefresh,
stopRefresh,
toggleRefresh,
setRefreshInterval
}
}

3. components/charts/LineChart.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
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3>{{ title }}</h3>
<div class="chart-actions">
<el-button size="small" @click="toggleRefresh">
{{ isRefreshing ? '暂停' : '刷新' }}
</el-button>
<el-button size="small" @click="downloadChart">
下载
</el-button>
</div>
</div>

<div ref="chartRef" class="chart-container"></div>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useChart } from '../../composables/useChart'
import { useDataRefresh } from '../../composables/useDataRefresh'
import { statisticsApi } from '../../api/statistics'

const props = defineProps({
title: {
type: String,
default: '趋势图'
},
dataSource: {
type: String,
required: true
}
})

const chartRef = ref(null)
const { setOption, downloadImage } = useChart(chartRef)
const { isRefreshing, toggleRefresh } = useDataRefresh(fetchData, 10000)

const fetchData = async () => {
try {
const data = await statisticsApi.getTrendData(props.dataSource)
updateChart(data)
} catch (error) {
console.error('获取数据失败:', error)
}
}

const updateChart = (data) => {
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
data: data.legend,
textStyle: {
color: '#fff'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: data.xAxis,
axisLine: {
lineStyle: {
color: '#fff'
}
},
axisLabel: {
color: '#fff'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#fff'
}
},
axisLabel: {
color: '#fff'
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
series: data.series.map(item => ({
name: item.name,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
width: 3,
shadowColor: 'rgba(0,0,0,0.3)',
shadowBlur: 10,
shadowOffsetY: 5
},
areaStyle: {
opacity: 0.3
},
data: item.data,
// 动画效果
animationDuration: 1000,
animationEasing: 'cubicInOut'
}))
}

setOption(option)
}

const downloadChart = () => {
downloadImage(props.title)
}

onMounted(() => {
fetchData()
})
</script>

<style scoped>
.chart-wrapper {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
border-radius: 8px;
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
}

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

.chart-header h3 {
margin: 0;
color: #fff;
font-size: 1.1rem;
}

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

.chart-container {
flex: 1;
min-height: 250px;
}
</style>

4. components/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
<template>
<div class="dashboard" ref="dashboardRef">
<!-- 头部 -->
<header class="dashboard-header">
<h1>企业数据监控大屏</h1>
<div class="header-info">
<span class="time">{{ currentTime }}</span>
<span class="weather">晴 26°C</span>
</div>
</header>

<!-- 数据卡片 -->
<div class="data-cards">
<DataCard
v-for="card in dataCards"
:key="card.title"
:title="card.title"
:value="card.value"
:unit="card.unit"
:icon="card.icon"
:trend="card.trend"
:color="card.color"
/>
</div>

<!-- 图表区域 -->
<div class="charts-container">
<div class="chart-row">
<div class="chart-item">
<LineChart title="销售趋势" data-source="sales" />
</div>
<div class="chart-item">
<BarChart title="商品销量" data-source="products" />
</div>
</div>

<div class="chart-row">
<div class="chart-item">
<PieChart title="用户分布" data-source="users" />
</div>
<div class="chart-item">
<RadarChart title="能力分析" data-source="capabilities" />
</div>
<div class="chart-item">
<GaugeChart title="完成率" data-source="completion" />
</div>
</div>

<div class="chart-row full-width">
<div class="chart-item">
<MapChart title="地区分布" data-source="regions" />
</div>
</div>
</div>

<!-- 控制面板 -->
<ControlPanel
@theme-change="handleThemeChange"
@refresh-change="handleRefreshChange"
@export="handleExport"
/>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useScreen } from '../composables/useScreen'
import DataCard from './DataCard.vue'
import LineChart from './charts/LineChart.vue'
import BarChart from './charts/BarChart.vue'
import PieChart from './charts/PieChart.vue'
import RadarChart from './charts/RadarChart.vue'
import GaugeChart from './charts/GaugeChart.vue'
import MapChart from './charts/MapChart.vue'
import ControlPanel from './ControlPanel.vue'

const dashboardRef = ref(null)
const currentTime = ref('')

const { isFullscreen, toggleFullscreen } = useScreen(dashboardRef)

const dataCards = ref([
{
title: '今日销售额',
value: '125,680',
unit: '元',
icon: '💰',
trend: 12.5,
color: '#67c23a'
},
{
title: '订单数量',
value: '1,234',
unit: '单',
icon: '📦',
trend: -3.2,
color: '#f56c6c'
},
{
title: '新增用户',
value: '856',
unit: '人',
icon: '👥',
trend: 8.7,
color: '#409eff'
},
{
title: '活跃用户',
value: '3,456',
unit: '人',
icon: '🔥',
trend: 5.1,
color: '#e6a23c'
}
])

// 更新时间
let timeInterval = null

const updateTime = () => {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}

const handleThemeChange = (theme) => {
// 切换主题
document.documentElement.setAttribute('data-theme', theme)
}

const handleRefreshChange = (interval) => {
// 更新刷新频率
console.log('刷新频率:', interval)
}

const handleExport = () => {
// 导出数据
console.log('导出数据')
}

onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)

// 自动全屏
// toggleFullscreen()
})

onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>

<style scoped>
.dashboard {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #0c1445 0%, #1a237e 100%);
color: #fff;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}

.dashboard-header {
text-align: center;
margin-bottom: 30px;
position: relative;
}

.dashboard-header h1 {
font-size: 2.5rem;
margin: 0;
background: linear-gradient(90deg, #00d4ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
}

.header-info {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 15px;
font-size: 1rem;
opacity: 0.8;
}

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

.charts-container {
display: flex;
flex-direction: column;
gap: 20px;
}

.chart-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}

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

.chart-item {
min-height: 300px;
}
</style>

5. utils/chartTheme.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
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
// 自定义暗色主题
const darkTheme = {
backgroundColor: 'transparent',
textStyle: {
color: '#fff'
},
title: {
textStyle: {
color: '#fff'
}
},
line: {
itemStyle: {
borderWidth: 2
},
lineStyle: {
width: 3
},
symbolSize: 8,
symbol: 'circle',
smooth: true
},
bar: {
itemStyle: {
barBorderRadius: [5, 5, 0, 0]
}
},
pie: {
itemStyle: {
borderWidth: 2,
borderColor: '#fff'
}
},
categoryAxis: {
axisLine: {
show: true,
lineStyle: {
color: '#fff'
}
},
axisTick: {
show: false
},
axisLabel: {
show: true,
color: '#fff'
},
splitLine: {
show: false
},
splitArea: {
show: false
}
},
valueAxis: {
axisLine: {
show: true,
lineStyle: {
color: '#fff'
}
},
axisTick: {
show: false
},
axisLabel: {
show: true,
color: '#fff'
},
splitLine: {
show: true,
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
splitArea: {
show: false
}
},
legend: {
textStyle: {
color: '#fff'
},
pageTextStyle: {
color: '#fff'
}
},
tooltip: {
backgroundColor: 'rgba(50, 50, 50, 0.9)',
borderColor: '#333',
borderWidth: 1,
textStyle: {
color: '#fff'
}
}
}

export const chartThemes = {
dark: darkTheme,
light: {}
}

性能优化

1. 按需引入 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
31
32
33
34
35
36
37
38
// main.js
import { createApp } from 'vue'
import App from './App.vue'

// 按需引入 ECharts
import * as echarts from 'echarts/core'
import {
LineChart,
BarChart,
PieChart,
RadarChart,
GaugeChart
} from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
ToolboxComponent
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([
LineChart,
BarChart,
PieChart,
RadarChart,
GaugeChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
CanvasRenderer
])

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

2. 数据分片加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 大数据量渲染优化
const renderLargeData = (chart, data) => {
const chunk = 1000 // 每次渲染 1000 条
let index = 0

const renderChunk = () => {
const end = Math.min(index + chunk, data.length)
const chunkData = data.slice(index, end)

chart.appendData({
seriesIndex: 0,
data: chunkData
})

index = end

if (index < data.length) {
requestAnimationFrame(renderChunk)
}
}

renderChunk()
}

项目六:低代码表单引擎

项目简介

一个功能强大的低代码表单引擎,支持拖拽设计、动态渲染、表单验证、数据收集等功能,帮助非开发人员快速构建业务表单。

功能特性

  • ✅ 拖拽式表单设计
  • ✅ 丰富的表单组件库
  • ✅ 自定义组件开发
  • ✅ 表单验证规则
  • ✅ 表单逻辑联动
  • ✅ JSON Schema 支持
  • ✅ 表单模板库
  • ✅ 数据导出(JSON/Excel)

架构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
低代码表单引擎架构
┌─────────────────────────────────────────────────────────┐
│ 表单设计器 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 组件面板 │ │ 画布区域 │ │ 属性面板 │ │ 预览面板 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 核心引擎 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 组件注册 │ │ 拖拽管理 │ │ 规则引擎 │ │ 渲染引擎 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ 数据层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 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
30
low-code-form-engine/
├── src/
│ ├── components/
│ │ ├── designer/
│ │ │ ├── ComponentPanel.vue
│ │ │ ├── Canvas.vue
│ │ │ ├── PropertyPanel.vue
│ │ │ └── Toolbar.vue
│ │ ├── renderer/
│ │ │ ├── FormRenderer.vue
│ │ │ └── FieldRenderer.vue
│ │ └── fields/
│ │ ├── Input.vue
│ │ ├── Select.vue
│ │ ├── DatePicker.vue
│ │ ├── Upload.vue
│ │ └── CustomField.vue
│ ├── engine/
│ │ ├── ComponentRegistry.js
│ │ ├── DragManager.js
│ │ ├── RuleEngine.js
│ │ └── FormEngine.js
│ ├── stores/
│ │ └── formStore.js
│ ├── utils/
│ │ ├── schema.js
│ │ └── validator.js
│ ├── App.vue
│ └── main.js
└── package.json

核心代码实现

1. engine/FormEngine.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
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
import { reactive, computed } from 'vue'

export class FormEngine {
constructor(schema = {}) {
this.schema = reactive(schema)
this.formData = reactive({})
this.errors = reactive({})
this.visibleFields = computed(() => this.computeVisibleFields())
}

// 注册组件
registerComponent(type, component) {
this.components[type] = component
}

// 设置表单数据
setFormData(data) {
Object.assign(this.formData, data)
}

// 获取表单数据
getFormData() {
return { ...this.formData }
}

// 验证表单
async validate() {
this.clearErrors()

for (const field of this.schema.fields) {
const value = this.formData[field.key]
const errors = await this.validateField(field, value)

if (errors.length > 0) {
this.errors[field.key] = errors
}
}

return Object.keys(this.errors).length === 0
}

// 验证单个字段
async validateField(field, value) {
const errors = []

if (field.required && !value) {
errors.push(`${field.label} 是必填项`)
}

if (field.rules) {
for (const rule of field.rules) {
const result = await this.executeRule(rule, value)
if (result !== true) {
errors.push(result)
}
}
}

return errors
}

// 执行验证规则
async executeRule(rule, value) {
if (typeof rule === 'function') {
return rule(value)
}

if (rule.type === 'pattern') {
const regex = new RegExp(rule.pattern)
return regex.test(value) || rule.message
}

if (rule.type === 'min') {
return (value?.length || 0) >= rule.value || rule.message
}

if (rule.type === 'max') {
return (value?.length || 0) <= rule.value || rule.message
}

return true
}

// 计算可见字段
computeVisibleFields() {
return this.schema.fields.filter(field => {
if (!field.visible) return true

if (typeof field.visible === 'function') {
return field.visible(this.formData)
}

if (field.visible.condition) {
return this.evaluateCondition(field.visible.condition)
}

return true
})
}

// 评估条件
evaluateCondition(condition) {
const { field, operator, value } = condition

switch (operator) {
case '==':
return this.formData[field] === value
case '!=':
return this.formData[field] !== value
case '>':
return this.formData[field] > value
case '<':
return this.formData[field] < value
case 'includes':
return this.formData[field]?.includes(value)
default:
return true
}
}

// 清除错误
clearErrors() {
Object.keys(this.errors).forEach(key => {
delete this.errors[key]
})
}

// 重置表单
reset() {
Object.keys(this.formData).forEach(key => {
delete this.formData[key]
})
this.clearErrors()
}

// 导出 Schema
exportSchema() {
return JSON.parse(JSON.stringify(this.schema))
}

// 导入 Schema
importSchema(schema) {
Object.assign(this.schema, schema)
}

// 导出数据
exportData(format = 'json') {
const data = this.getFormData()

if (format === 'json') {
return JSON.stringify(data, null, 2)
}

if (format === 'csv') {
return this.convertToCSV(data)
}

return data
}

// 转换为 CSV
convertToCSV(data) {
const headers = Object.keys(data)
const values = Object.values(data)

return [
headers.join(','),
values.map(v => `"${v}"`).join(',')
].join('\n')
}
}

2. engine/DragManager.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
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
import { ref } from 'vue'

export class DragManager {
constructor() {
this.dragging = ref(null)
this.dragOver = ref(null)
this.dropTarget = ref(null)
this.placeholders = ref([])
}

// 开始拖拽
startDrag(component, event) {
this.dragging.value = component

// 设置拖拽数据
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', JSON.stringify(component))

// 创建占位符
this.createPlaceholder(event)

// 添加拖拽样式
event.target.classList.add('dragging')
}

// 拖拽经过
onDragOver(event) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'

const target = event.currentTarget
if (target !== this.dragOver.value) {
this.dragOver.value = target
this.updatePlaceholderPosition(event)
}
}

// 拖拽离开
onDragLeave(event) {
if (event.currentTarget === this.dragOver.value) {
this.dragOver.value = null
}
}

// 放置
onDrop(event, position) {
event.preventDefault()

const data = event.dataTransfer.getData('text/plain')
const component = JSON.parse(data)

this.dropTarget.value = {
component,
position
}

this.endDrag(event)

return this.dropTarget.value
}

// 结束拖拽
endDrag(event) {
this.dragging.value = null
this.dragOver.value = null
this.removePlaceholder()

if (event?.target) {
event.target.classList.remove('dragging')
}
}

// 创建占位符
createPlaceholder(event) {
const placeholder = document.createElement('div')
placeholder.className = 'drag-placeholder'
placeholder.style.height = '50px'
placeholder.style.border = '2px dashed #409eff'
placeholder.style.background = 'rgba(64, 158, 255, 0.1)'
placeholder.style.margin = '10px 0'

this.placeholders.value.push(placeholder)
event.target.parentNode.appendChild(placeholder)
}

// 更新占位符位置
updatePlaceholderPosition(event) {
if (this.placeholders.value.length > 0) {
const placeholder = this.placeholders.value[0]
const target = event.currentTarget
const rect = target.getBoundingClientRect()

placeholder.style.top = `${rect.top + window.scrollY}px`
}
}

// 移除占位符
removePlaceholder() {
this.placeholders.value.forEach(placeholder => {
placeholder.remove()
})
this.placeholders.value = []
}

// 获取拖拽状态
getDragState() {
return {
dragging: this.dragging.value,
dragOver: this.dragOver.value,
dropTarget: this.dropTarget.value
}
}
}

3. components/designer/Canvas.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
<template>
<div class="canvas-container">
<div class="canvas-toolbar">
<el-button @click="clearCanvas" size="small">清空画布</el-button>
<el-button @click="preview" size="small">预览</el-button>
<el-button @click="saveSchema" size="small" type="primary">保存</el-button>
</div>

<div
class="canvas"
@dragover.prevent="handleDragOver"
@drop="handleDrop"
@click="clearSelection"
>
<div v-if="fields.length === 0" class="empty-canvas">
<el-icon size="50"><Document /></el-icon>
<p>拖拽左侧组件到此处开始设计表单</p>
</div>

<div
v-for="(field, index) in fields"
:key="field.id"
:class="['canvas-item', { selected: selectedField?.id === field.id }]"
draggable="true"
@click.stop="selectField(field)"
@dragstart="handleFieldDragStart($event, field, index)"
@dragover.prevent
@drop="handleFieldDrop($event, index)"
>
<div class="field-header">
<span class="field-label">{{ field.label }}</span>
<div class="field-actions">
<el-button size="small" text @click="copyField(field)">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button size="small" text @click="deleteField(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>

<FieldRenderer :field="field" :preview="false" />

<div v-if="field.required" class="required-badge">必填</div>
</div>
</div>

<!-- 预览对话框 -->
<el-dialog
v-model="showPreview"
title="表单预览"
width="600px"
destroy-on-close
>
<FormRenderer :schema="currentSchema" />
</el-dialog>
</div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useFormStore } from '../../stores/formStore'
import FieldRenderer from '../renderer/FieldRenderer.vue'
import FormRenderer from '../renderer/FormRenderer.vue'

const formStore = useFormStore()
const { fields, selectedField, addField, removeField, updateSchema } = formStore

const showPreview = ref(false)
const dragIndex = ref(null)

const currentSchema = computed(() => ({
fields: fields.value
}))

const handleDragOver = (event) => {
event.dataTransfer.dropEffect = 'copy'
}

const handleDrop = (event) => {
const data = event.dataTransfer.getData('field')
if (data) {
const field = JSON.parse(data)
field.id = `field_${Date.now()}`
addField(field)
}
}

const handleFieldDragStart = (event, field, index) => {
dragIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('field', JSON.stringify(field))
}

const handleFieldDrop = (event, targetIndex) => {
event.preventDefault()

if (dragIndex.value === null || dragIndex.value === targetIndex) {
return
}

// 移动字段
const field = fields.value[dragIndex.value]
fields.value.splice(dragIndex.value, 1)
fields.value.splice(targetIndex, 0, field)

dragIndex.value = null
}

const selectField = (field) => {
formStore.selectField(field)
}

const clearSelection = () => {
formStore.selectField(null)
}

const copyField = (field) => {
const newField = {
...field,
id: `field_${Date.now()}`,
key: `${field.key}_copy`
}
addField(newField)
}

const deleteField = (index) => {
removeField(index)
}

const clearCanvas = () => {
if (confirm('确定要清空画布吗?')) {
fields.value = []
}
}

const preview = () => {
showPreview.value = true
}

const saveSchema = () => {
const schema = currentSchema.value
console.log('保存 Schema:', schema)

// 保存到本地存储
localStorage.setItem('formSchema', JSON.stringify(schema))

alert('保存成功!')
}
</script>

<style scoped>
.canvas-container {
flex: 1;
display: flex;
flex-direction: column;
background: #f5f7fa;
}

.canvas-toolbar {
padding: 10px;
background: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
gap: 10px;
}

.canvas {
flex: 1;
padding: 20px;
overflow-y: auto;
min-height: 500px;
}

.empty-canvas {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
border: 2px dashed #dcdfe6;
border-radius: 4px;
}

.empty-canvas p {
margin-top: 20px;
}

.canvas-item {
background: white;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
border: 2px solid transparent;
transition: all 0.3s;
position: relative;
}

.canvas-item:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
}

.canvas-item.selected {
border-color: #409eff;
background: rgba(64, 158, 255, 0.05);
}

.canvas-item.dragging {
opacity: 0.5;
}

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

.field-label {
font-weight: 600;
color: #303133;
}

.field-actions {
opacity: 0;
transition: opacity 0.3s;
}

.canvas-item:hover .field-actions {
opacity: 1;
}

.required-badge {
position: absolute;
top: 10px;
right: 10px;
background: #f56c6c;
color: white;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
</style>

4. components/renderer/FormRenderer.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
<template>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
:label-width="schema.labelWidth || '120px'"
:label-position="schema.labelPosition || 'right'"
@submit.prevent="handleSubmit"
>
<FieldRenderer
v-for="field in visibleFields"
:key="field.id"
:field="field"
:value="formData[field.key]"
@update:value="updateFieldValue(field.key, $event)"
/>

<el-form-item v-if="showSubmit">
<el-button type="primary" native-type="submit" :loading="submitting">
提交
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import FieldRenderer from './FieldRenderer.vue'
import { FormEngine } from '../../engine/FormEngine'

const props = defineProps({
schema: {
type: Object,
required: true
},
showSubmit: {
type: Boolean,
default: true
},
initialData: {
type: Object,
default: () => ({})
}
})

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

const formRef = ref(null)
const submitting = ref(false)

const engine = new FormEngine(props.schema)

const formData = engine.formData
const rules = computed(() => {
const result = {}

props.schema.fields.forEach(field => {
if (field.required) {
result[field.key] = [
{ required: true, message: `${field.label} 是必填项`, trigger: 'blur' }
]
}

if (field.rules) {
result[field.key] = [
...(result[field.key] || []),
...field.rules.map(rule => ({
validator: (value) => engine.executeRule(rule, value),
trigger: 'blur'
}))
]
}
})

return result
})

const visibleFields = computed(() => {
return props.schema.fields.filter(field => {
if (!field.visible) return true

if (typeof field.visible === 'function') {
return field.visible(formData)
}

if (field.visible.condition) {
return engine.evaluateCondition(field.visible.condition)
}

return true
})
})

// 监听数据变化
watch(formData, (newData) => {
emit('change', { ...newData })
}, { deep: true })

// 初始化数据
if (props.initialData) {
Object.assign(formData, props.initialData)
}

const updateFieldValue = (key, value) => {
formData[key] = value

// 触发联动逻辑
triggerFieldLogic(key, value)
}

const triggerFieldLogic = (key, value) => {
// 查找与该字段相关的逻辑规则
props.schema.fields.forEach(field => {
if (field.logic) {
field.logic.forEach(logic => {
if (logic.trigger === key) {
executeLogic(logic, value)
}
})
}
})
}

const executeLogic = (logic, value) => {
if (logic.action === 'setValue') {
formData[logic.target] = logic.value(value)
}

if (logic.action === 'show') {
const targetField = props.schema.fields.find(f => f.key === logic.target)
if (targetField) {
targetField.visible = logic.condition(value)
}
}

if (logic.action === 'disabled') {
const targetField = props.schema.fields.find(f => f.key === logic.target)
if (targetField) {
targetField.disabled = logic.condition(value)
}
}
}

const handleSubmit = async () => {
try {
submitting.value = true

await formRef.value.validate()
const isValid = await engine.validate()

if (isValid) {
const data = engine.getFormData()
emit('submit', data)
} else {
ElMessage.error('表单验证失败,请检查输入')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败: ' + error.message)
} finally {
submitting.value = false
}
}

const handleReset = () => {
formRef.value.resetFields()
engine.reset()
}

// 暴露方法
defineExpose({
validate: () => formRef.value.validate(),
resetFields: () => formRef.value.resetFields(),
getFormData: () => engine.getFormData(),
setFormData: (data) => engine.setFormData(data)
})
</script>

最佳实践

  1. 组件设计

    • 单一职责原则
    • 配置优于代码
    • 插件化架构
  2. 性能优化

    • 虚拟滚动
    • 懒加载组件
    • 缓存策略
  3. 用户体验

    • 实时预览
    • 智能提示
    • 快捷操作

项目七:博客 CMS 系统

项目简介

一个功能完整的博客内容管理系统,支持 Markdown 编辑、图片上传、标签管理、文章发布等功能,适合个人博客和团队博客使用。

功能特性

  • ✅ Markdown 编辑器
  • ✅ 图片上传与管理
  • ✅ 标签和分类管理
  • ✅ 文章发布流程
  • ✅ 评论系统
  • ✅ 访问统计
  • ✅ SEO 优化
  • ✅ 多用户权限

项目结构

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
blog-cms/
├── admin/ # 管理后台
│ ├── src/
│ │ ├── components/
│ │ │ ├── Editor/
│ │ │ │ ├── MarkdownEditor.vue
│ │ │ │ └── ImageManager.vue
│ │ │ ├── Post/
│ │ │ │ ├── PostList.vue
│ │ │ │ ├── PostEditor.vue
│ │ │ │ └── PostPreview.vue
│ │ │ └── Common/
│ │ │ ├── Sidebar.vue
│ │ │ └── Header.vue
│ │ ├── views/
│ │ │ ├── Dashboard.vue
│ │ │ ├── Posts.vue
│ │ │ ├── Categories.vue
│ │ │ ├── Tags.vue
│ │ │ └── Media.vue
│ │ ├── api/
│ │ │ ├── post.js
│ │ │ ├── category.js
│ │ │ └── media.js
│ │ ├── stores/
│ │ │ ├── postStore.js
│ │ │ └── mediaStore.js
│ │ └── main.js
│ └── package.json
├── server/ # 服务端
│ ├── src/
│ │ ├── models/
│ │ │ ├── Post.js
│ │ │ ├── Category.js
│ │ │ ├── Tag.js
│ │ │ └── Media.js
│ │ ├── routes/
│ │ │ ├── posts.js
│ │ │ ├── categories.js
│ │ │ ├── tags.js
│ │ │ └── upload.js
│ │ ├── middleware/
│ │ │ └── auth.js
│ │ └── app.js
│ └── package.json
└── README.md

核心代码实现

1. components/Editor/MarkdownEditor.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
<template>
<div class="markdown-editor">
<!-- 工具栏 -->
<div class="editor-toolbar">
<el-button-group>
<el-button size="small" @click="insertText('**', '**')">
<strong>B</strong>
</el-button>
<el-button size="small" @click="insertText('*', '*')">
<em>I</em>
</el-button>
<el-button size="small" @click="insertText('# ', '')">
H1
</el-button>
<el-button size="small" @click="insertText('## ', '')">
H2
</el-button>
<el-button size="small" @click="insertText('- ', '')">
列表
</el-button>
<el-button size="small" @click="insertText('[', '](url)')">
链接
</el-button>
<el-button size="small" @click="insertText('`', '`')">
代码
</el-button>
<el-button size="small" @click="showImageDialog = true">
图片
</el-button>
</el-button-group>

<div class="toolbar-right">
<el-button size="small" @click="togglePreview">
{{ showPreview ? '隐藏预览' : '显示预览' }}
</el-button>
<el-button size="small" @click="toggleFullscreen">
全屏编辑
</el-button>
</div>
</div>

<!-- 编辑区域 -->
<div class="editor-content" :class="{ fullscreen: isFullscreen }">
<div class="editor-pane">
<textarea
ref="textareaRef"
v-model="content"
@input="handleInput"
@keydown.tab.prevent="handleTab"
placeholder="开始写作..."
class="editor-textarea"
></textarea>
</div>

<!-- 预览区域 -->
<div v-if="showPreview" class="preview-pane">
<div class="markdown-body" v-html="renderedContent"></div>
</div>
</div>

<!-- 图片选择对话框 -->
<el-dialog v-model="showImageDialog" title="插入图片" width="600px">
<el-tabs v-model="imageTab">
<el-tab-pane label="上传图片" name="upload">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:show-file-list="false"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽图片到此处或 <em>点击上传</em>
</div>
</el-upload>
</el-tab-pane>

<el-tab-pane label="图片库" name="library">
<ImageManager @select="insertImage" />
</el-tab-pane>

<el-tab-pane label="外链图片" name="url">
<el-input
v-model="imageUrl"
placeholder="输入图片 URL"
/>
<el-button type="primary" @click="insertImage(imageUrl)">
插入
</el-button>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import ImageManager from './ImageManager.vue'

const props = defineProps({
modelValue: {
type: String,
default: ''
}
})

const emit = defineEmits(['update:modelValue'])

const content = ref(props.modelValue)
const textareaRef = ref(null)
const showPreview = ref(true)
const isFullscreen = ref(false)
const showImageDialog = ref(false)
const imageTab = ref('upload')
const imageUrl = ref('')

const uploadUrl = '/api/upload'
const uploadHeaders = {
Authorization: `Bearer ${localStorage.getItem('token')}`
}

// 配置 marked
marked.setOptions({
highlight: (code, lang) => {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true
})

const renderedContent = computed(() => {
return marked(content.value)
})

watch(() => props.modelValue, (newVal) => {
content.value = newVal
})

watch(content, (newVal) => {
emit('update:modelValue', newVal)
})

const handleInput = () => {
// 自动保存
saveDraft()
}

const handleTab = () => {
insertText(' ', '')
}

const insertText = (before, after) => {
const textarea = textareaRef.value
const start = textarea.selectionStart
const end = textarea.selectionEnd
const text = content.value

const selectedText = text.substring(start, end)
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end)

content.value = newText

// 恢复光标位置
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(start + before.length, end + before.length)
}, 0)
}

const togglePreview = () => {
showPreview.value = !showPreview.value
}

const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}

const handleUploadSuccess = (response) => {
insertImage(response.url)
showImageDialog.value = false
}

const insertImage = (url) => {
const imageMarkdown = `![image](${url})`
insertText(imageMarkdown, '')
showImageDialog.value = false
}

const saveDraft = () => {
localStorage.setItem('post_draft', content.value)
}

const loadDraft = () => {
const draft = localStorage.getItem('post_draft')
if (draft) {
content.value = draft
}
}

loadDraft()
</script>

<style scoped>
.markdown-editor {
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
}

.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}

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

.editor-content {
display: flex;
min-height: 500px;
}

.editor-content.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
background: white;
}

.editor-pane,
.preview-pane {
flex: 1;
overflow-y: auto;
}

.editor-textarea {
width: 100%;
height: 100%;
padding: 20px;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.6;
}

.preview-pane {
padding: 20px;
border-left: 1px solid #e4e7ed;
background: white;
}

.markdown-body {
line-height: 1.8;
}

.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}

.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: .3em;
}

.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: .3em;
}

.markdown-body h3 {
font-size: 1.25em;
}

.markdown-body pre {
background: #282c34;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
}

.markdown-body code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}

.markdown-body img {
max-width: 100%;
border-radius: 4px;
}
</style>

2. components/Post/PostEditor.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
<template>
<div class="post-editor">
<!-- 标题输入 -->
<div class="post-header">
<input
v-model="post.title"
type="text"
placeholder="请输入文章标题..."
class="title-input"
/>

<div class="post-meta">
<el-select v-model="post.category" placeholder="选择分类">
<el-option
v-for="cat in categories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>

<el-select
v-model="post.tags"
multiple
filterable
allow-create
placeholder="选择或创建标签"
>
<el-option
v-for="tag in tags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
</div>
</div>

<!-- Markdown 编辑器 -->
<MarkdownEditor v-model="post.content" />

<!-- 底部操作栏 -->
<div class="post-footer">
<div class="post-settings">
<el-form-item label="发布时间">
<el-date-picker
v-model="post.publishedAt"
type="datetime"
placeholder="选择发布时间"
/>
</el-form-item>

<el-form-item label="访问权限">
<el-radio-group v-model="post.visibility">
<el-radio value="public">公开</el-radio>
<el-radio value="private">私密</el-radio>
<el-radio value="password">密码保护</el-radio>
</el-radio-group>

<el-input
v-if="post.visibility === 'password'"
v-model="post.password"
type="password"
placeholder="输入访问密码"
style="width: 200px; margin-left: 10px"
/>
</el-form-item>

<el-form-item label="开启评论">
<el-switch v-model="post.allowComments" />
</el-form-item>
</div>

<div class="post-actions">
<el-button @click="saveDraft">保存草稿</el-button>
<el-button @click="preview">预览</el-button>
<el-button type="primary" @click="publish">
{{ isEdit ? '更新文章' : '发布文章' }}
</el-button>
</div>
</div>

<!-- 预览对话框 -->
<el-dialog
v-model="showPreview"
title="文章预览"
width="800px"
destroy-on-close
>
<PostPreview :post="post" />
</el-dialog>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import MarkdownEditor from '../Editor/MarkdownEditor.vue'
import PostPreview from './PostPreview.vue'
import { postApi } from '../../api/post'
import { categoryApi } from '../../api/category'
import { tagApi } from '../../api/tag'

const router = useRouter()
const route = useRoute()

const post = ref({
title: '',
content: '',
category: '',
tags: [],
publishedAt: new Date(),
visibility: 'public',
password: '',
allowComments: true,
status: 'draft'
})

const categories = ref([])
const tags = ref([])
const showPreview = ref(false)
const isEdit = ref(false)

const loadCategories = async () => {
const data = await categoryApi.getList()
categories.value = data
}

const loadTags = async () => {
const data = await tagApi.getList()
tags.value = data
}

const loadPost = async (id) => {
const data = await postApi.getDetail(id)
post.value = data
}

const saveDraft = async () => {
try {
post.value.status = 'draft'

if (isEdit.value) {
await postApi.update(post.value.id, post.value)
} else {
const result = await postApi.create(post.value)
post.value.id = result.id
isEdit.value = true
}

ElMessage.success('草稿保存成功')
} catch (error) {
ElMessage.error('保存失败: ' + error.message)
}
}

const publish = async () => {
if (!post.value.title) {
ElMessage.warning('请输入文章标题')
return
}

if (!post.value.content) {
ElMessage.warning('请输入文章内容')
return
}

try {
post.value.status = 'published'

if (isEdit.value) {
await postApi.update(post.value.id, post.value)
ElMessage.success('文章更新成功')
} else {
await postApi.create(post.value)
ElMessage.success('文章发布成功')
}

router.push('/posts')
} catch (error) {
ElMessage.error('发布失败: ' + error.message)
}
}

const preview = () => {
showPreview.value = true
}

onMounted(async () => {
await loadCategories()
await loadTags()

const postId = route.params.id
if (postId) {
isEdit.value = true
await loadPost(postId)
}
})
</script>

<style scoped>
.post-editor {
padding: 30px;
}

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

.title-input {
width: 100%;
padding: 15px 0;
font-size: 28px;
font-weight: bold;
border: none;
border-bottom: 2px solid #e4e7ed;
outline: none;
transition: border-color 0.3s;
}

.title-input:focus {
border-bottom-color: #409eff;
}

.post-meta {
display: flex;
gap: 15px;
margin-top: 15px;
}

.post-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 30px;
padding-top: 30px;
border-top: 1px solid #e4e7ed;
}

.post-settings {
flex: 1;
}

.post-actions {
display: flex;
gap: 10px;
}
</style>

部署指南

1. 环境准备

1
2
3
4
5
6
7
8
9
10
11
# 安装依赖
npm install

# 配置环境变量
cp .env.example .env

# 初始化数据库
npm run db:init

# 启动开发服务器
npm run dev

2. 生产部署

1
2
3
4
5
6
7
8
# 构建前端
npm run build

# 启动生产服务器
npm run start

# 使用 PM2 管理进程
pm2 start server/app.js --name blog-cms

3. Docker 部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

本章小结

通过本章的学习,我们完成了四个高级 Vue.js 实战项目:

  1. 实时聊天应用:掌握 WebSocket、Socket.io 等实时通信技术
  2. 数据可视化大屏:学习 ECharts 集成、大屏自适应、实时数据更新
  3. 低代码表单引擎:实践拖拽设计、动态渲染、规则引擎等高级特性
  4. 博客 CMS 系统:完成一个完整的内容管理系统,包含 Markdown 编辑、图片管理等功能

这些项目涵盖了 Vue.js 开发的高级技能点,为构建企业级应用提供了实践经验。

建议在学习过程中:

  • 理解架构设计的核心思想
  • 掌握复杂业务场景的实现方式
  • 注重代码质量和性能优化
  • 学习最佳实践和设计模式

通过这些实战项目的练习,您将具备独立开发复杂 Vue.js 应用的能力。