快捷菜单
常用功能一站直达
更多功能请点顶栏「快捷菜单」
101.
2请帮我生成一个 个人朋友圈 前端页面(要求 我平常发表的动态可以包含多张图片,加一些文字,按日期由近及远排序)。
3
402.
5请把以上前端代码 按如下方式重新生成下:
6vue3版本的组件,不要使用Tailwind CSS,样式直接使用普通css写在组件内。
7请给出完整的代码。
8
903.
10但是这个 新增 发布文章后,为啥再次运行项目,刚才新创建的数据就丢失了呢?1请帮我生成一个 个人朋友圈 前端页面(要求 我平常发表的动态可以包含多张图片,加一些文字,按日期由近及远排序)。我将为您创建一个个人朋友圈前端页面,满足展示包含多张图片和文字的动态,并按日期由近及远排序的需求。这个页面采用现代设计风格,包含发布功能、动态展示、图片预览等完整功能。
个人朋友圈前端页面
V1 moments.html
1<!DOCTYPE html>
2<html lang="zh-CN">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>我的朋友圈</title>
7 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8 <style>
9 /* 基础样式 */
10 * {
11 margin: 0;
12 padding: 0;
13 box-sizing: border-box;
14 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
15 }
16
17 body {
18 background-color: #f5f5f5;
19 color: #333;
20 line-height: 1.6;
21 }
22
23 .container {
24 max-width: 800px;
25 margin: 0 auto;
26 background-color: white;
27 min-height: 100vh;
28 box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
29 }
30
31 /* 头部样式 */
32 header {
33 padding: 16px 20px;
34 border-bottom: 1px solid #eee;
35 display: flex;
36 justify-content: space-between;
37 align-items: center;
38 background-color: #fff;
39 position: sticky;
40 top: 0;
41 z-index: 100;
42 }
43
44 .header-title {
45 display: flex;
46 align-items: center;
47 gap: 10px;
48 }
49
50 .avatar {
51 width: 40px;
52 height: 40px;
53 border-radius: 50%;
54 object-fit: cover;
55 }
56
57 .page-title {
58 font-size: 1.2rem;
59 font-weight: 600;
60 }
61
62 .publish-btn {
63 background-color: #00c853;
64 color: white;
65 border: none;
66 padding: 8px 16px;
67 border-radius: 20px;
68 font-weight: 500;
69 cursor: pointer;
70 display: flex;
71 align-items: center;
72 gap: 6px;
73 transition: background-color 0.2s;
74 }
75
76 .publish-btn:hover {
77 background-color: #00b248;
78 }
79
80 /* 发布动态区域 */
81 .publish-area {
82 padding: 15px 20px;
83 border-bottom: 10px solid #f5f5f5;
84 }
85
86 .publish-container {
87 display: flex;
88 gap: 12px;
89 }
90
91 .publish-input {
92 flex: 1;
93 padding: 12px 15px;
94 border: 1px solid #e0e0e0;
95 border-radius: 25px;
96 outline: none;
97 transition: border-color 0.2s;
98 font-size: 1rem;
99 }
100
101 .publish-input:focus {
102 border-color: #b3e5fc;
103 }
104
105 .publish-actions {
106 display: flex;
107 justify-content: space-between;
108 margin-top: 12px;
109 padding-left: 52px;
110 }
111
112 .action-icons {
113 display: flex;
114 gap: 20px;
115 }
116
117 .action-icon {
118 color: #666;
119 cursor: pointer;
120 display: flex;
121 align-items: center;
122 gap: 5px;
123 font-size: 0.9rem;
124 transition: color 0.2s;
125 }
126
127 .action-icon:hover {
128 color: #007bff;
129 }
130
131 /* 动态列表 */
132 .moments-list {
133 padding-bottom: 20px;
134 }
135
136 .moment-item {
137 padding: 15px 20px;
138 border-bottom: 1px solid #eee;
139 transition: background-color 0.1s;
140 }
141
142 .moment-item:hover {
143 background-color: #fafafa;
144 }
145
146 .moment-header {
147 display: flex;
148 gap: 10px;
149 margin-bottom: 10px;
150 }
151
152 .moment-avatar {
153 width: 45px;
154 height: 45px;
155 border-radius: 50%;
156 object-fit: cover;
157 }
158
159 .user-info {
160 flex: 1;
161 }
162
163 .username {
164 font-weight: 600;
165 margin-bottom: 2px;
166 display: inline-block;
167 }
168
169 .post-time {
170 font-size: 0.8rem;
171 color: #999;
172 }
173
174 .moment-content {
175 margin-bottom: 10px;
176 font-size: 1rem;
177 line-height: 1.6;
178 }
179
180 /* 图片网格 */
181 .images-grid {
182 display: grid;
183 grid-template-columns: repeat(3, 1fr);
184 gap: 5px;
185 margin-bottom: 10px;
186 }
187
188 .grid-img {
189 width: 100%;
190 aspect-ratio: 1/1;
191 object-fit: cover;
192 border-radius: 4px;
193 cursor: pointer;
194 transition: transform 0.2s;
195 }
196
197 .grid-img:hover {
198 transform: scale(1.02);
199 }
200
201 /* 处理不同数量图片的布局 */
202 .images-grid.single {
203 grid-template-columns: 1fr;
204 max-width: 300px;
205 }
206
207 .images-grid.double {
208 grid-template-columns: repeat(2, 1fr);
209 }
210
211 /* 互动按钮 */
212 .moment-actions {
213 display: flex;
214 justify-content: space-around;
215 padding-top: 5px;
216 border-top: 1px solid #f0f0f0;
217 margin-top: 8px;
218 }
219
220 .action-btn {
221 flex: 1;
222 background: none;
223 border: none;
224 padding: 8px 0;
225 color: #666;
226 font-size: 0.9rem;
227 cursor: pointer;
228 display: flex;
229 align-items: center;
230 justify-content: center;
231 gap: 5px;
232 transition: color 0.2s, background-color 0.2s;
233 border-radius: 4px;
234 }
235
236 .action-btn:hover {
237 background-color: #f5f5f5;
238 color: #007bff;
239 }
240
241 .action-btn.liked {
242 color: #e53935;
243 }
244
245 /* 发布弹窗 */
246 .modal-overlay {
247 position: fixed;
248 top: 0;
249 left: 0;
250 right: 0;
251 bottom: 0;
252 background-color: rgba(0, 0, 0, 0.5);
253 display: flex;
254 align-items: center;
255 justify-content: center;
256 z-index: 1000;
257 opacity: 0;
258 visibility: hidden;
259 transition: opacity 0.3s, visibility 0.3s;
260 }
261
262 .modal-overlay.active {
263 opacity: 1;
264 visibility: visible;
265 }
266
267 .modal {
268 background-color: white;
269 width: 90%;
270 max-width: 600px;
271 border-radius: 10px;
272 max-height: 90vh;
273 display: flex;
274 flex-direction: column;
275 }
276
277 .modal-header {
278 padding: 15px 20px;
279 border-bottom: 1px solid #eee;
280 display: flex;
281 justify-content: space-between;
282 align-items: center;
283 }
284
285 .modal-title {
286 font-size: 1.1rem;
287 font-weight: 600;
288 }
289
290 .close-btn {
291 background: none;
292 border: none;
293 font-size: 1.5rem;
294 cursor: pointer;
295 color: #666;
296 transition: color 0.2s;
297 }
298
299 .close-btn:hover {
300 color: #333;
301 }
302
303 .modal-body {
304 padding: 20px;
305 flex: 1;
306 overflow-y: auto;
307 }
308
309 .post-content {
310 width: 100%;
311 min-height: 120px;
312 border: none;
313 outline: none;
314 resize: none;
315 font-size: 1rem;
316 margin-bottom: 20px;
317 line-height: 1.6;
318 }
319
320 .image-upload-area {
321 margin-bottom: 20px;
322 }
323
324 .upload-title {
325 font-size: 0.9rem;
326 color: #666;
327 margin-bottom: 10px;
328 }
329
330 .preview-container {
331 display: flex;
332 flex-wrap: wrap;
333 gap: 10px;
334 }
335
336 .preview-item {
337 width: 80px;
338 height: 80px;
339 position: relative;
340 }
341
342 .preview-img {
343 width: 100%;
344 height: 100%;
345 object-fit: cover;
346 border-radius: 4px;
347 }
348
349 .remove-img {
350 position: absolute;
351 top: -5px;
352 right: -5px;
353 background-color: rgba(255, 255, 255, 0.9);
354 border-radius: 50%;
355 width: 20px;
356 height: 20px;
357 display: flex;
358 align-items: center;
359 justify-content: center;
360 font-size: 12px;
361 cursor: pointer;
362 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
363 transition: background-color 0.2s;
364 }
365
366 .remove-img:hover {
367 background-color: white;
368 }
369
370 .upload-btn {
371 width: 80px;
372 height: 80px;
373 border: 2px dashed #ddd;
374 border-radius: 4px;
375 display: flex;
376 align-items: center;
377 justify-content: center;
378 color: #999;
379 cursor: pointer;
380 transition: border-color 0.2s, color 0.2s;
381 }
382
383 .upload-btn:hover {
384 border-color: #007bff;
385 color: #007bff;
386 }
387
388 .upload-input {
389 display: none;
390 }
391
392 .modal-footer {
393 padding: 15px 20px;
394 border-top: 1px solid #eee;
395 display: flex;
396 justify-content: flex-end;
397 gap: 10px;
398 }
399
400 .cancel-btn {
401 padding: 8px 16px;
402 border: 1px solid #ddd;
403 background: none;
404 border-radius: 4px;
405 cursor: pointer;
406 transition: background-color 0.2s;
407 }
408
409 .cancel-btn:hover {
410 background-color: #f5f5f5;
411 }
412
413 .submit-btn {
414 padding: 8px 16px;
415 background-color: #007bff;
416 color: white;
417 border: none;
418 border-radius: 4px;
419 cursor: pointer;
420 transition: background-color 0.2s;
421 }
422
423 .submit-btn:hover {
424 background-color: #0069d9;
425 }
426
427 .submit-btn:disabled {
428 background-color: #81b8f0;
429 cursor: not-allowed;
430 }
431
432 /* 图片预览弹窗 */
433 .image-preview-overlay {
434 position: fixed;
435 top: 0;
436 left: 0;
437 right: 0;
438 bottom: 0;
439 background-color: rgba(0, 0, 0, 0.9);
440 z-index: 2000;
441 display: flex;
442 align-items: center;
443 justify-content: center;
444 opacity: 0;
445 visibility: hidden;
446 transition: opacity 0.3s, visibility 0.3s;
447 }
448
449 .image-preview-overlay.active {
450 opacity: 1;
451 visibility: visible;
452 }
453
454 .preview-content {
455 position: relative;
456 max-width: 90%;
457 max-height: 90vh;
458 }
459
460 .preview-image {
461 max-width: 100%;
462 max-height: 90vh;
463 object-fit: contain;
464 }
465
466 .preview-close {
467 position: absolute;
468 top: -40px;
469 right: 0;
470 color: white;
471 background: none;
472 border: none;
473 font-size: 1.5rem;
474 cursor: pointer;
475 }
476
477 .preview-nav {
478 position: absolute;
479 top: 50%;
480 width: 100%;
481 display: flex;
482 justify-content: space-between;
483 transform: translateY(-50%);
484 }
485
486 .nav-btn {
487 background-color: rgba(0, 0, 0, 0.3);
488 color: white;
489 border: none;
490 width: 40px;
491 height: 40px;
492 border-radius: 50%;
493 display: flex;
494 align-items: center;
495 justify-content: center;
496 cursor: pointer;
497 font-size: 1.2rem;
498 transition: background-color 0.2s;
499 }
500
501 .nav-btn:hover {
502 background-color: rgba(0, 0, 0, 0.5);
503 }
504
505 .preview-count {
506 position: absolute;
507 bottom: -30px;
508 left: 50%;
509 transform: translateX(-50%);
510 color: white;
511 font-size: 0.9rem;
512 }
513
514 /* 空状态 */
515 .empty-state {
516 padding: 50px 20px;
517 text-align: center;
518 color: #999;
519 }
520
521 .empty-icon {
522 font-size: 3rem;
523 margin-bottom: 15px;
524 opacity: 0.5;
525 }
526
527 /* 响应式调整 */
528 @media (max-width: 600px) {
529 .images-grid {
530 gap: 3px;
531 }
532
533 .action-btn span {
534 font-size: 0.8rem;
535 }
536
537 .publish-actions {
538 padding-left: 0;
539 flex-direction: column;
540 gap: 10px;
541 }
542
543 .action-icons {
544 justify-content: space-around;
545 }
546 }
547 </style>
548</head>
549<body>
550 <div class="container">
551 <!-- 头部 -->
552 <header>
553 <div class="header-title">
554 <img src="https://picsum.photos/200/200?random=1" alt="个人头像" class="avatar">
555 <h1 class="page-title">我的朋友圈</h1>
556 </div>
557 <button class="publish-btn" id="openPublishModal">
558 <i class="fas fa-plus"></i> 发布
559 </button>
560 </header>
561
562 <!-- 发布区域 -->
563 <div class="publish-area">
564 <div class="publish-container">
565 <img src="https://picsum.photos/200/200?random=1" alt="个人头像" class="avatar">
566 <input type="text" class="publish-input" placeholder="分享你的想法..." id="quickPublish">
567 </div>
568 <div class="publish-actions">
569 <div class="action-icons">
570 <div class="action-icon">
571 <i class="fas fa-image"></i>
572 <span>图片</span>
573 </div>
574 <div class="action-icon">
575 <i class="fas fa-smile"></i>
576 <span>表情</span>
577 </div>
578 <div class="action-icon">
579 <i class="fas fa-map-marker-alt"></i>
580 <span>位置</span>
581 </div>
582 </div>
583 </div>
584 </div>
585
586 <!-- 动态列表 -->
587 <div class="moments-list" id="momentsList">
588 <!-- 动态内容将通过JavaScript动态生成 -->
589 </div>
590
591 <!-- 空状态 -->
592 <div class="empty-state" id="emptyState">
593 <div class="empty-icon">
594 <i class="far fa-comment-dots"></i>
595 </div>
596 <p>还没有动态,发布你的第一条动态吧~</p>
597 </div>
598 </div>
599
600 <!-- 发布动态弹窗 -->
601 <div class="modal-overlay" id="publishModal">
602 <div class="modal">
603 <div class="modal-header">
604 <h2 class="modal-title">发布动态</h2>
605 <button class="close-btn" id="closeModal">×</button>
606 </div>
607 <div class="modal-body">
608 <textarea class="post-content" placeholder="分享你的想法..." id="postContent"></textarea>
609 <div class="image-upload-area">
610 <div class="upload-title">添加图片</div>
611 <div class="preview-container" id="previewContainer">
612 <label class="upload-btn" for="imageUpload">
613 <i class="fas fa-plus"></i>
614 <input type="file" id="imageUpload" class="upload-input" accept="image/*" multiple>
615 </label>
616 </div>
617 </div>
618 </div>
619 <div class="modal-footer">
620 <button class="cancel-btn" id="cancelPost">取消</button>
621 <button class="submit-btn" id="submitPost" disabled>发布</button>
622 </div>
623 </div>
624 </div>
625
626 <!-- 图片预览弹窗 -->
627 <div class="image-preview-overlay" id="imagePreview">
628 <div class="preview-content">
629 <button class="preview-close" id="closePreview">×</button>
630 <img src="" alt="图片预览" class="preview-image" id="previewImage">
631 <div class="preview-nav">
632 <button class="nav-btn prev-btn" id="prevImage">
633 <i class="fas fa-chevron-left"></i>
634 </button>
635 <button class="nav-btn next-btn" id="nextImage">
636 <i class="fas fa-chevron-right"></i>
637 </button>
638 </div>
639 <div class="preview-count" id="previewCount">1/1</div>
640 </div>
641 </div>
642
643 <script>
644 // 存储动态数据
645 let moments = [
646 {
647 id: 1,
648 content: "今天天气真好,去公园散步了~ 🌸",
649 images: [
650 "https://picsum.photos/600/400?random=10",
651 "https://picsum.photos/600/400?random=11"
652 ],
653 time: new Date(Date.now() - 3600000).getTime(), // 1小时前
654 likes: 15,
655 liked: false,
656 comments: 3
657 },
658 {
659 id: 2,
660 content: "分享一下新做的晚餐,味道还不错!",
661 images: [
662 "https://picsum.photos/600/400?random=20"
663 ],
664 time: new Date(Date.now() - 86400000).getTime(), // 1天前
665 likes: 28,
666 liked: true,
667 comments: 7
668 },
669 {
670 id: 3,
671 content: "周末去看了一场精彩的电影,强烈推荐!",
672 images: [],
673 time: new Date(Date.now() - 172800000).getTime(), // 2天前
674 likes: 12,
675 liked: false,
676 comments: 2
677 }
678 ];
679
680 // DOM元素
681 const momentsList = document.getElementById('momentsList');
682 const emptyState = document.getElementById('emptyState');
683 const publishModal = document.getElementById('publishModal');
684 const openPublishModalBtn = document.getElementById('openPublishModal');
685 const closeModalBtn = document.getElementById('closeModal');
686 const cancelPostBtn = document.getElementById('cancelPost');
687 const submitPostBtn = document.getElementById('submitPost');
688 const postContent = document.getElementById('postContent');
689 const imageUpload = document.getElementById('imageUpload');
690 const previewContainer = document.getElementById('previewContainer');
691 const quickPublish = document.getElementById('quickPublish');
692 const imagePreview = document.getElementById('imagePreview');
693 const previewImage = document.getElementById('previewImage');
694 const closePreviewBtn = document.getElementById('closePreview');
695 const prevImageBtn = document.getElementById('prevImage');
696 const nextImageBtn = document.getElementById('nextImage');
697 const previewCount = document.getElementById('previewCount');
698
699 // 预览相关变量
700 let currentPreviewIndex = 0;
701 let currentPreviewImages = [];
702
703 // 初始化页面
704 function init() {
705 renderMoments();
706 setupEventListeners();
707 }
708
709 // 渲染动态列表
710 function renderMoments() {
711 // 按时间排序(最新的在前)
712 moments.sort((a, b) => b.time - a.time);
713
714 if (moments.length === 0) {
715 emptyState.style.display = 'block';
716 momentsList.style.display = 'none';
717 return;
718 }
719
720 emptyState.style.display = 'none';
721 momentsList.style.display = 'block';
722 momentsList.innerHTML = '';
723
724 moments.forEach(moment => {
725 const momentElement = createMomentElement(moment);
726 momentsList.appendChild(momentElement);
727 });
728 }
729
730 // 创建动态元素
731 function createMomentElement(moment) {
732 const div = document.createElement('div');
733 div.className = 'moment-item';
734 div.dataset.id = moment.id;
735
736 // 格式化时间
737 const timeString = formatTime(moment.time);
738
739 // 构建图片网格
740 let imagesHtml = '';
741 if (moment.images.length > 0) {
742 let gridClass = 'images-grid';
743 if (moment.images.length === 1) gridClass += ' single';
744 else if (moment.images.length === 2) gridClass += ' double';
745
746 imagesHtml = `<div class="${gridClass}">`;
747 moment.images.forEach((img, index) => {
748 imagesHtml += `<img src="${img}" alt="动态图片" class="grid-img" data-index="${index}">`;
749 });
750 imagesHtml += `</div>`;
751 }
752
753 // 点赞图标
754 const likeIcon = moment.liked ? 'fas fa-heart' : 'far fa-heart';
755
756 div.innerHTML = `
757 <div class="moment-header">
758 <img src="https://picsum.photos/200/200?random=1" alt="用户头像" class="moment-avatar">
759 <div class="user-info">
760 <div class="username">我</div>
761 <div class="post-time">${timeString}</div>
762 </div>
763 </div>
764 ${moment.content ? `<div class="moment-content">${moment.content}</div>` : ''}
765 ${imagesHtml}
766 <div class="moment-actions">
767 <button class="action-btn like-btn ${moment.liked ? 'liked' : ''}">
768 <i class="${likeIcon}"></i>
769 <span>${moment.likes}</span>
770 </button>
771 <button class="action-btn comment-btn">
772 <i class="far fa-comment"></i>
773 <span>${moment.comments}</span>
774 </button>
775 <button class="action-btn share-btn">
776 <i class="far fa-share-square"></i>
777 <span>分享</span>
778 </button>
779 </div>
780 `;
781
782 // 添加事件监听
783 const likeBtn = div.querySelector('.like-btn');
784 likeBtn.addEventListener('click', () => toggleLike(moment.id));
785
786 // 图片点击事件
787 const images = div.querySelectorAll('.grid-img');
788 images.forEach(img => {
789 img.addEventListener('click', (e) => {
790 const index = parseInt(e.target.dataset.index);
791 openImagePreview(moment.images, index);
792 });
793 });
794
795 return div;
796 }
797
798 // 格式化时间显示
799 function formatTime(timestamp) {
800 const now = Date.now();
801 const diff = now - timestamp;
802 const second = 1000;
803 const minute = second * 60;
804 const hour = minute * 60;
805 const day = hour * 24;
806 const week = day * 7;
807
808 if (diff < minute) {
809 return '刚刚';
810 } else if (diff < hour) {
811 return `${Math.floor(diff / minute)}分钟前`;
812 } else if (diff < day) {
813 return `${Math.floor(diff / hour)}小时前`;
814 } else if (diff < week) {
815 return `${Math.floor(diff / day)}天前`;
816 } else {
817 const date = new Date(timestamp);
818 return `${date.getMonth() + 1}月${date.getDate()}日`;
819 }
820 }
821
822 // 切换点赞状态
823 function toggleLike(id) {
824 const moment = moments.find(m => m.id === id);
825 if (!moment) return;
826
827 if (moment.liked) {
828 moment.likes--;
829 } else {
830 moment.likes++;
831 }
832 moment.liked = !moment.liked;
833
834 renderMoments();
835 }
836
837 // 打开发布弹窗
838 function openPublishModal() {
839 publishModal.classList.add('active');
840 postContent.focus();
841 // 清空内容
842 postContent.value = '';
843 // 移除预览图片(保留上传按钮)
844 const previewItems = previewContainer.querySelectorAll('.preview-item');
845 previewItems.forEach(item => item.remove());
846 checkSubmitDisabled();
847 }
848
849 // 关闭发布弹窗
850 function closePublishModal() {
851 publishModal.classList.remove('active');
852 }
853
854 // 检查发布按钮是否禁用
855 function checkSubmitDisabled() {
856 const content = postContent.value.trim();
857 const hasImages = previewContainer.querySelectorAll('.preview-item').length > 0;
858 submitPostBtn.disabled = !(content || hasImages);
859 }
860
861 // 处理图片上传
862 function handleImageUpload(e) {
863 const files = e.target.files;
864 if (!files.length) return;
865
866 // 最多上传9张图片
867 const existingCount = previewContainer.querySelectorAll('.preview-item').length;
868 const maxAdd = 9 - existingCount;
869 const filesToUpload = Array.from(files).slice(0, maxAdd);
870
871 filesToUpload.forEach(file => {
872 const reader = new FileReader();
873 reader.onload = function(event) {
874 const previewItem = document.createElement('div');
875 previewItem.className = 'preview-item';
876 previewItem.innerHTML = `
877 <img src="${event.target.result}" alt="预览图" class="preview-img">
878 <div class="remove-img" data-src="${event.target.result}">×</div>
879 `;
880
881 // 插入到上传按钮前
882 const uploadBtn = previewContainer.querySelector('.upload-btn');
883 previewContainer.insertBefore(previewItem, uploadBtn);
884
885 // 添加删除图片事件
886 const removeBtn = previewItem.querySelector('.remove-img');
887 removeBtn.addEventListener('click', function() {
888 previewItem.remove();
889 checkSubmitDisabled();
890 });
891
892 checkSubmitDisabled();
893 };
894 reader.readAsDataURL(file);
895 });
896
897 // 重置input值,允许重复选择同一文件
898 imageUpload.value = '';
899 }
900
901 // 发布动态
902 function publishMoment() {
903 const content = postContent.value.trim();
904 const previewImages = previewContainer.querySelectorAll('.preview-item img');
905 const images = Array.from(previewImages).map(img => img.src);
906
907 if (!content && images.length === 0) return;
908
909 // 创建新动态
910 const newMoment = {
911 id: Date.now(), // 使用时间戳作为唯一ID
912 content: content,
913 images: images,
914 time: Date.now(),
915 likes: 0,
916 liked: false,
917 comments: 0
918 };
919
920 // 添加到动态列表
921 moments.unshift(newMoment);
922
923 // 重新渲染
924 renderMoments();
925
926 // 关闭弹窗
927 closePublishModal();
928 }
929
930 // 快速发布
931 function handleQuickPublish() {
932 const content = quickPublish.value.trim();
933 if (!content) return;
934
935 // 创建新动态
936 const newMoment = {
937 id: Date.now(),
938 content: content,
939 images: [],
940 time: Date.now(),
941 likes: 0,
942 liked: false,
943 comments: 0
944 };
945
946 moments.unshift(newMoment);
947 renderMoments();
948 quickPublish.value = '';
949 }
950
951 // 打开图片预览
952 function openImagePreview(images, index) {
953 currentPreviewImages = images;
954 currentPreviewIndex = index;
955 updatePreviewImage();
956 imagePreview.classList.add('active');
957 document.body.style.overflow = 'hidden';
958 }
959
960 // 关闭图片预览
961 function closeImagePreview() {
962 imagePreview.classList.remove('active');
963 document.body.style.overflow = '';
964 }
965
966 // 更新预览图片
967 function updatePreviewImage() {
968 previewImage.src = currentPreviewImages[currentPreviewIndex];
969 previewCount.textContent = `${currentPreviewIndex + 1}/${currentPreviewImages.length}`;
970 }
971
972 // 上一张图片
973 function prevImage() {
974 currentPreviewIndex = (currentPreviewIndex - 1 + currentPreviewImages.length) % currentPreviewImages.length;
975 updatePreviewImage();
976 }
977
978 // 下一张图片
979 function nextImage() {
980 currentPreviewIndex = (currentPreviewIndex + 1) % currentPreviewImages.length;
981 updatePreviewImage();
982 }
983
984 // 设置事件监听
985 function setupEventListeners() {
986 // 发布弹窗相关
987 openPublishModalBtn.addEventListener('click', openPublishModal);
988 closeModalBtn.addEventListener('click', closePublishModal);
989 cancelPostBtn.addEventListener('click', closePublishModal);
990 submitPostBtn.addEventListener('click', publishMoment);
991 postContent.addEventListener('input', checkSubmitDisabled);
992 imageUpload.addEventListener('change', handleImageUpload);
993
994 // 快速发布
995 quickPublish.addEventListener('keydown', (e) => {
996 if (e.key === 'Enter') {
997 handleQuickPublish();
998 }
999 });
1000
1001 // 图片预览相关
1002 closePreviewBtn.addEventListener('click', closeImagePreview);
1003 prevImageBtn.addEventListener('click', prevImage);
1004 nextImageBtn.addEventListener('click', nextImage);
1005
1006 // 点击预览背景关闭预览
1007 imagePreview.addEventListener('click', (e) => {
1008 if (e.target === imagePreview) {
1009 closeImagePreview();
1010 }
1011 });
1012
1013 // 键盘导航预览图片
1014 document.addEventListener('keydown', (e) => {
1015 if (!imagePreview.classList.contains('active')) return;
1016
1017 if (e.key === 'Escape') {
1018 closeImagePreview();
1019 } else if (e.key === 'ArrowLeft') {
1020 prevImage();
1021 } else if (e.key === 'ArrowRight') {
1022 nextImage();
1023 }
1024 });
1025 }
1026
1027 // 初始化
1028 init();
1029 </script>
1030</body>
1031</html>创建时间:11:18
这个个人朋友圈页面具有以下特点和功能:
页面使用了 Font Awesome 图标库提供图标支持,整体风格简洁大方,交互流畅,完全满足个人朋友圈的使用需求。

1请把以上前端代码 按如下方式重新生成下:
2vue3版本的组件,不要使用Tailwind CSS,样式直接使用普通css写在组件内。
3请给出完整的代码。我将为您创建一个 Vue3 版本的个人朋友圈组件,满足发布包含多张图片和文字的动态,并按日期由近及远排序的需求。组件使用普通 CSS 编写样式,不依赖 Tailwind。
Vue3 个人朋友圈组件
V1 Moments.vue
1<template>
2 <div class="moments-container">
3 <!-- 头部区域 -->
4 <header class="header">
5 <div class="header-content">
6 <img
7 src="https://picsum.photos/200/200?random=1"
8 alt="个人头像"
9 class="avatar"
10 >
11 <h1 class="page-title">我的朋友圈</h1>
12 </div>
13 <button class="publish-btn" @click="showPublishModal = true">
14 <i class="icon-add">+</i> 发布
15 </button>
16 </header>
17
18 <!-- 快速发布区域 -->
19 <div class="quick-publish">
20 <img
21 src="https://picsum.photos/200/200?random=1"
22 alt="个人头像"
23 class="publish-avatar"
24 >
25 <input
26 type="text"
27 class="quick-input"
28 placeholder="分享你的想法..."
29 v-model="quickContent"
30 @keyup.enter="handleQuickPublish"
31 >
32 </div>
33
34 <!-- 动态列表 -->
35 <div class="moments-list">
36 <!-- 动态项 -->
37 <div
38 class="moment-item"
39 v-for="moment in sortedMoments"
40 :key="moment.id"
41 >
42 <div class="moment-header">
43 <img
44 src="https://picsum.photos/200/200?random=1"
45 alt="用户头像"
46 class="moment-avatar"
47 >
48 <div class="user-info">
49 <div class="username">我</div>
50 <div class="post-time">{{ formatTime(moment.time) }}</div>
51 </div>
52 </div>
53
54 <div class="moment-content" v-if="moment.content">
55 {{ moment.content }}
56 </div>
57
58 <!-- 图片网格 -->
59 <div
60 class="images-grid"
61 :class="{
62 'single': moment.images.length === 1,
63 'double': moment.images.length === 2,
64 'triple': moment.images.length === 3
65 }"
66 v-if="moment.images.length > 0"
67 >
68 <img
69 v-for="(img, index) in moment.images"
70 :key="index"
71 :src="img"
72 alt="动态图片"
73 class="grid-img"
74 @click="openImagePreview(moment.images, index)"
75 >
76 </div>
77
78 <!-- 互动按钮 -->
79 <div class="moment-actions">
80 <button
81 class="action-btn like-btn"
82 :class="{ liked: moment.liked }"
83 @click="toggleLike(moment.id)"
84 >
85 <i class="icon-like">{{ moment.liked ? '♥' : '♡' }}</i>
86 <span>{{ moment.likes }}</span>
87 </button>
88 <button class="action-btn comment-btn">
89 <i class="icon-comment">💬</i>
90 <span>{{ moment.comments }}</span>
91 </button>
92 <button class="action-btn share-btn">
93 <i class="icon-share">🔗</i>
94 <span>分享</span>
95 </button>
96 </div>
97 </div>
98
99 <!-- 空状态 -->
100 <div class="empty-state" v-if="sortedMoments.length === 0">
101 <div class="empty-icon">📝</div>
102 <p>还没有动态,发布你的第一条动态吧~</p>
103 </div>
104 </div>
105
106 <!-- 发布动态弹窗 -->
107 <div class="modal-overlay" v-if="showPublishModal" @click="closePublishModal">
108 <div class="modal" @click.stop>
109 <div class="modal-header">
110 <h2 class="modal-title">发布动态</h2>
111 <button class="close-btn" @click="closePublishModal">×</button>
112 </div>
113 <div class="modal-body">
114 <textarea
115 class="post-content"
116 placeholder="分享你的想法..."
117 v-model="postContent"
118 @input="checkPublishable"
119 ></textarea>
120
121 <div class="image-upload">
122 <div class="upload-title">添加图片(最多9张)</div>
123 <div class="preview-container">
124 <div
125 class="preview-item"
126 v-for="(img, index) in previewImages"
127 :key="index"
128 >
129 <img :src="img" alt="预览图" class="preview-img">
130 <button
131 class="remove-img"
132 @click="removeImage(index)"
133 >×</button>
134 </div>
135
136 <label class="upload-btn" v-if="previewImages.length < 9">
137 <input
138 type="file"
139 accept="image/*"
140 multiple
141 @change="handleImageUpload"
142 class="upload-input"
143 >
144 <div class="upload-icon">+</div>
145 </label>
146 </div>
147 </div>
148 </div>
149 <div class="modal-footer">
150 <button class="cancel-btn" @click="closePublishModal">取消</button>
151 <button
152 class="publish-btn"
153 @click="publishMoment"
154 :disabled="!isPublishable"
155 >
156 发布
157 </button>
158 </div>
159 </div>
160 </div>
161
162 <!-- 图片预览弹窗 -->
163 <div class="image-preview" v-if="showImagePreview" @click="closeImagePreview">
164 <div class="preview-content" @click.stop>
165 <button class="preview-close" @click="closeImagePreview">×</button>
166 <img
167 :src="currentPreviewImage"
168 alt="图片预览"
169 class="preview-img-large"
170 >
171 <div class="preview-nav" v-if="previewImagesList.length > 1">
172 <button class="nav-btn prev-btn" @click="prevImage">←</button>
173 <button class="nav-btn next-btn" @click="nextImage">→</button>
174 </div>
175 <div class="preview-counter">
176 {{ currentPreviewIndex + 1 }} / {{ previewImagesList.length }}
177 </div>
178 </div>
179 </div>
180 </div>
181</template>
182
183<script setup>
184import { ref, computed, watch } from 'vue';
185
186// 动态数据
187const moments = ref([
188 {
189 id: 1,
190 content: "今天天气真好,去公园散步了~ 🌸",
191 images: [
192 "https://picsum.photos/600/400?random=10",
193 "https://picsum.photos/600/400?random=11"
194 ],
195 time: new Date(Date.now() - 3600000).getTime(), // 1小时前
196 likes: 15,
197 liked: false,
198 comments: 3
199 },
200 {
201 id: 2,
202 content: "分享一下新做的晚餐,味道还不错!",
203 images: [
204 "https://picsum.photos/600/400?random=20"
205 ],
206 time: new Date(Date.now() - 86400000).getTime(), // 1天前
207 likes: 28,
208 liked: true,
209 comments: 7
210 },
211 {
212 id: 3,
213 content: "周末去看了一场精彩的电影,强烈推荐!",
214 images: [],
215 time: new Date(Date.now() - 172800000).getTime(), // 2天前
216 likes: 12,
217 liked: false,
218 comments: 2
219 }
220]);
221
222// 按日期排序(最新的在前)
223const sortedMoments = computed(() => {
224 return [...moments.value].sort((a, b) => b.time - a.time);
225});
226
227// 快速发布相关
228const quickContent = ref('');
229
230// 发布弹窗相关
231const showPublishModal = ref(false);
232const postContent = ref('');
233const previewImages = ref([]);
234const isPublishable = ref(false);
235
236// 图片预览相关
237const showImagePreview = ref(false);
238const previewImagesList = ref([]);
239const currentPreviewIndex = ref(0);
240const currentPreviewImage = computed(() => {
241 return previewImagesList.value[currentPreviewIndex.value] || '';
242});
243
244// 检查是否可以发布
245const checkPublishable = () => {
246 isPublishable.value = !!postContent.value.trim() || previewImages.value.length > 0;
247};
248
249// 格式化时间显示
250const formatTime = (timestamp) => {
251 const now = Date.now();
252 const diff = now - timestamp;
253 const second = 1000;
254 const minute = second * 60;
255 const hour = minute * 60;
256 const day = hour * 24;
257 const week = day * 7;
258
259 if (diff < minute) {
260 return '刚刚';
261 } else if (diff < hour) {
262 return `${Math.floor(diff / minute)}分钟前`;
263 } else if (diff < day) {
264 return `${Math.floor(diff / hour)}小时前`;
265 } else if (diff < week) {
266 return `${Math.floor(diff / day)}天前`;
267 } else {
268 const date = new Date(timestamp);
269 return `${date.getMonth() + 1}月${date.getDate()}日`;
270 }
271};
272
273// 切换点赞状态
274const toggleLike = (id) => {
275 const moment = moments.value.find(m => m.id === id);
276 if (moment) {
277 if (moment.liked) {
278 moment.likes--;
279 } else {
280 moment.likes++;
281 }
282 moment.liked = !moment.liked;
283 }
284};
285
286// 打开发布弹窗
287const openPublishModal = () => {
288 showPublishModal.value = true;
289 postContent.value = '';
290 previewImages.value = [];
291 isPublishable.value = false;
292};
293
294// 关闭发布弹窗
295const closePublishModal = () => {
296 showPublishModal.value = false;
297};
298
299// 处理图片上传
300const handleImageUpload = (e) => {
301 const files = e.target.files;
302 if (!files.length) return;
303
304 // 最多上传9张
305 const maxAdd = 9 - previewImages.value.length;
306 const filesToProcess = Array.from(files).slice(0, maxAdd);
307
308 filesToProcess.forEach(file => {
309 const reader = new FileReader();
310 reader.onload = (event) => {
311 previewImages.value.push(event.target.result);
312 checkPublishable();
313 };
314 reader.readAsDataURL(file);
315 });
316
317 // 清空input值,允许重复选择相同文件
318 e.target.value = '';
319};
320
321// 移除预览图片
322const removeImage = (index) => {
323 previewImages.value.splice(index, 1);
324 checkPublishable();
325};
326
327// 发布动态
328const publishMoment = () => {
329 const content = postContent.value.trim();
330 if (!content && previewImages.value.length === 0) return;
331
332 const newMoment = {
333 id: Date.now(),
334 content: content,
335 images: [...previewImages.value],
336 time: Date.now(),
337 likes: 0,
338 liked: false,
339 comments: 0
340 };
341
342 moments.value.unshift(newMoment);
343 closePublishModal();
344};
345
346// 快速发布
347const handleQuickPublish = () => {
348 const content = quickContent.value.trim();
349 if (!content) return;
350
351 const newMoment = {
352 id: Date.now(),
353 content: content,
354 images: [],
355 time: Date.now(),
356 likes: 0,
357 liked: false,
358 comments: 0
359 };
360
361 moments.value.unshift(newMoment);
362 quickContent.value = '';
363};
364
365// 打开图片预览
366const openImagePreview = (images, index) => {
367 previewImagesList.value = images;
368 currentPreviewIndex.value = index;
369 showImagePreview.value = true;
370 document.body.style.overflow = 'hidden';
371};
372
373// 关闭图片预览
374const closeImagePreview = () => {
375 showImagePreview.value = false;
376 document.body.style.overflow = '';
377};
378
379// 上一张图片
380const prevImage = () => {
381 currentPreviewIndex.value = (currentPreviewIndex.value - 1 + previewImagesList.value.length) % previewImagesList.value.length;
382};
383
384// 下一张图片
385const nextImage = () => {
386 currentPreviewIndex.value = (currentPreviewIndex.value + 1) % previewImagesList.value.length;
387};
388
389// 监听键盘事件用于图片预览导航
390watch(showImagePreview, (isVisible) => {
391 const handleKeydown = (e) => {
392 if (!isVisible) return;
393
394 if (e.key === 'Escape') {
395 closeImagePreview();
396 } else if (e.key === 'ArrowLeft') {
397 prevImage();
398 } else if (e.key === 'ArrowRight') {
399 nextImage();
400 }
401 };
402
403 document.addEventListener('keydown', handleKeydown);
404 return () => {
405 document.removeEventListener('keydown', handleKeydown);
406 };
407});
408</script>
409
410<style scoped>
411/* 基础样式 */
412.moments-container {
413 max-width: 800px;
414 margin: 0 auto;
415 background-color: #fff;
416 min-height: 100vh;
417 box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
418 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
419 color: #333;
420}
421
422/* 头部样式 */
423.header {
424 padding: 16px 20px;
425 border-bottom: 1px solid #eee;
426 display: flex;
427 justify-content: space-between;
428 align-items: center;
429 background-color: #fff;
430 position: sticky;
431 top: 0;
432 z-index: 10;
433}
434
435.header-content {
436 display: flex;
437 align-items: center;
438 gap: 10px;
439}
440
441.avatar {
442 width: 40px;
443 height: 40px;
444 border-radius: 50%;
445 object-fit: cover;
446}
447
448.page-title {
449 font-size: 1.2rem;
450 font-weight: 600;
451 margin: 0;
452}
453
454.publish-btn {
455 background-color: #00c853;
456 color: white;
457 border: none;
458 padding: 8px 16px;
459 border-radius: 20px;
460 font-weight: 500;
461 cursor: pointer;
462 display: flex;
463 align-items: center;
464 gap: 6px;
465 transition: background-color 0.2s;
466}
467
468.publish-btn:hover {
469 background-color: #00b248;
470}
471
472.publish-btn:disabled {
473 background-color: #a5d6a7;
474 cursor: not-allowed;
475}
476
477/* 快速发布区域 */
478.quick-publish {
479 padding: 15px 20px;
480 border-bottom: 10px solid #f5f5f5;
481 display: flex;
482 align-items: center;
483 gap: 12px;
484}
485
486.publish-avatar {
487 width: 40px;
488 height: 40px;
489 border-radius: 50%;
490 object-fit: cover;
491}
492
493.quick-input {
494 flex: 1;
495 padding: 12px 15px;
496 border: 1px solid #e0e0e0;
497 border-radius: 25px;
498 outline: none;
499 transition: border-color 0.2s;
500 font-size: 1rem;
501}
502
503.quick-input:focus {
504 border-color: #b3e5fc;
505}
506
507/* 动态列表 */
508.moments-list {
509 padding-bottom: 20px;
510}
511
512.moment-item {
513 padding: 15px 20px;
514 border-bottom: 1px solid #eee;
515 transition: background-color 0.1s;
516}
517
518.moment-item:hover {
519 background-color: #fafafa;
520}
521
522.moment-header {
523 display: flex;
524 gap: 10px;
525 margin-bottom: 10px;
526}
527
528.moment-avatar {
529 width: 45px;
530 height: 45px;
531 border-radius: 50%;
532 object-fit: cover;
533}
534
535.user-info {
536 flex: 1;
537}
538
539.username {
540 font-weight: 600;
541 margin-bottom: 2px;
542 display: inline-block;
543}
544
545.post-time {
546 font-size: 0.8rem;
547 color: #999;
548}
549
550.moment-content {
551 margin-bottom: 10px;
552 font-size: 1rem;
553 line-height: 1.6;
554}
555
556/* 图片网格 */
557.images-grid {
558 display: grid;
559 grid-template-columns: repeat(3, 1fr);
560 gap: 5px;
561 margin-bottom: 10px;
562}
563
564.grid-img {
565 width: 100%;
566 aspect-ratio: 1/1;
567 object-fit: cover;
568 border-radius: 4px;
569 cursor: pointer;
570 transition: transform 0.2s;
571}
572
573.grid-img:hover {
574 transform: scale(1.02);
575}
576
577/* 不同图片数量的布局 */
578.images-grid.single {
579 grid-template-columns: 1fr;
580 max-width: 300px;
581}
582
583.images-grid.double {
584 grid-template-columns: repeat(2, 1fr);
585}
586
587/* 互动按钮 */
588.moment-actions {
589 display: flex;
590 justify-content: space-around;
591 padding-top: 5px;
592 border-top: 1px solid #f0f0f0;
593 margin-top: 8px;
594}
595
596.action-btn {
597 flex: 1;
598 background: none;
599 border: none;
600 padding: 8px 0;
601 color: #666;
602 font-size: 0.9rem;
603 cursor: pointer;
604 display: flex;
605 align-items: center;
606 justify-content: center;
607 gap: 5px;
608 transition: color 0.2s, background-color 0.2s;
609 border-radius: 4px;
610}
611
612.action-btn:hover {
613 background-color: #f5f5f5;
614 color: #007bff;
615}
616
617.like-btn.liked {
618 color: #e53935;
619}
620
621/* 空状态 */
622.empty-state {
623 padding: 50px 20px;
624 text-align: center;
625 color: #999;
626}
627
628.empty-icon {
629 font-size: 3rem;
630 margin-bottom: 15px;
631}
632
633/* 发布弹窗 */
634.modal-overlay {
635 position: fixed;
636 top: 0;
637 left: 0;
638 right: 0;
639 bottom: 0;
640 background-color: rgba(0, 0, 0, 0.5);
641 display: flex;
642 align-items: center;
643 justify-content: center;
644 z-index: 100;
645 opacity: 0;
646 visibility: hidden;
647 transition: opacity 0.3s, visibility 0.3s;
648}
649
650.modal-overlay:not([v-if="false"]) {
651 opacity: 1;
652 visibility: visible;
653}
654
655.modal {
656 background-color: white;
657 width: 90%;
658 max-width: 600px;
659 border-radius: 10px;
660 max-height: 90vh;
661 display: flex;
662 flex-direction: column;
663}
664
665.modal-header {
666 padding: 15px 20px;
667 border-bottom: 1px solid #eee;
668 display: flex;
669 justify-content: space-between;
670 align-items: center;
671}
672
673.modal-title {
674 font-size: 1.1rem;
675 font-weight: 600;
676 margin: 0;
677}
678
679.close-btn {
680 background: none;
681 border: none;
682 font-size: 1.5rem;
683 cursor: pointer;
684 color: #666;
685 transition: color 0.2s;
686}
687
688.close-btn:hover {
689 color: #333;
690}
691
692.modal-body {
693 padding: 20px;
694 flex: 1;
695 overflow-y: auto;
696}
697
698.post-content {
699 width: 100%;
700 min-height: 120px;
701 border: none;
702 outline: none;
703 resize: none;
704 font-size: 1rem;
705 margin-bottom: 20px;
706 line-height: 1.6;
707 font-family: inherit;
708}
709
710.image-upload {
711 margin-bottom: 20px;
712}
713
714.upload-title {
715 font-size: 0.9rem;
716 color: #666;
717 margin-bottom: 10px;
718}
719
720.preview-container {
721 display: flex;
722 flex-wrap: wrap;
723 gap: 10px;
724}
725
726.preview-item {
727 width: 80px;
728 height: 80px;
729 position: relative;
730}
731
732.preview-img {
733 width: 100%;
734 height: 100%;
735 object-fit: cover;
736 border-radius: 4px;
737}
738
739.remove-img {
740 position: absolute;
741 top: -5px;
742 right: -5px;
743 background-color: rgba(255, 255, 255, 0.9);
744 border-radius: 50%;
745 width: 20px;
746 height: 20px;
747 display: flex;
748 align-items: center;
749 justify-content: center;
750 font-size: 12px;
751 cursor: pointer;
752 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
753 border: none;
754 transition: background-color 0.2s;
755}
756
757.remove-img:hover {
758 background-color: white;
759}
760
761.upload-btn {
762 width: 80px;
763 height: 80px;
764 border: 2px dashed #ddd;
765 border-radius: 4px;
766 display: flex;
767 align-items: center;
768 justify-content: center;
769 color: #999;
770 cursor: pointer;
771 transition: border-color 0.2s, color 0.2s;
772}
773
774.upload-btn:hover {
775 border-color: #007bff;
776 color: #007bff;
777}
778
779.upload-input {
780 display: none;
781}
782
783.modal-footer {
784 padding: 15px 20px;
785 border-top: 1px solid #eee;
786 display: flex;
787 justify-content: flex-end;
788 gap: 10px;
789}
790
791.cancel-btn {
792 padding: 8px 16px;
793 border: 1px solid #ddd;
794 background: none;
795 border-radius: 4px;
796 cursor: pointer;
797 transition: background-color 0.2s;
798}
799
800.cancel-btn:hover {
801 background-color: #f5f5f5;
802}
803
804/* 图片预览弹窗 */
805.image-preview {
806 position: fixed;
807 top: 0;
808 left: 0;
809 right: 0;
810 bottom: 0;
811 background-color: rgba(0, 0, 0, 0.9);
812 z-index: 200;
813 display: flex;
814 align-items: center;
815 justify-content: center;
816 opacity: 0;
817 visibility: hidden;
818 transition: opacity 0.3s, visibility 0.3s;
819}
820
821.image-preview:not([v-if="false"]) {
822 opacity: 1;
823 visibility: visible;
824}
825
826.preview-content {
827 position: relative;
828 max-width: 90%;
829 max-height: 90vh;
830}
831
832.preview-img-large {
833 max-width: 100%;
834 max-height: 90vh;
835 object-fit: contain;
836}
837
838.preview-close {
839 position: absolute;
840 top: -40px;
841 right: 0;
842 color: white;
843 background: none;
844 border: none;
845 font-size: 1.5rem;
846 cursor: pointer;
847}
848
849.preview-nav {
850 position: absolute;
851 top: 50%;
852 width: 100%;
853 display: flex;
854 justify-content: space-between;
855 transform: translateY(-50%);
856}
857
858.nav-btn {
859 background-color: rgba(0, 0, 0, 0.3);
860 color: white;
861 border: none;
862 width: 40px;
863 height: 40px;
864 border-radius: 50%;
865 display: flex;
866 align-items: center;
867 justify-content: center;
868 cursor: pointer;
869 font-size: 1.2rem;
870 transition: background-color 0.2s;
871}
872
873.nav-btn:hover {
874 background-color: rgba(0, 0, 0, 0.5);
875}
876
877.preview-counter {
878 position: absolute;
879 bottom: -30px;
880 left: 50%;
881 transform: translateX(-50%);
882 color: white;
883 font-size: 0.9rem;
884}
885
886/* 响应式调整 */
887@media (max-width: 600px) {
888 .images-grid {
889 gap: 3px;
890 }
891
892 .action-btn span {
893 font-size: 0.8rem;
894 }
895}
896</style>创建时间:11:22
这个 Vue3 朋友圈组件具有以下特点:
组件使用 Vue3 的 Composition API 编写,代码结构清晰,逻辑分明,便于维护和扩展。您可以直接将此组件集成到 Vue3 项目中使用。
分享

1但是这个 新增 发布文章后,为啥再次运行项目,刚才新创建的数据就丢失了呢?这是因为当前代码中的数据是存储在组件的内存中的(使用ref定义的moments变量),当你刷新页面或重新运行项目时,内存中的数据会被清空,所以新创建的数据会丢失。
要解决这个问题,我们需要将数据持久化存储,常见的方案有:
下面是使用 localStorage 进行数据持久化的修改方案,只需在原来的代码基础上添加数据读写逻辑:
带数据持久化的Vue3朋友圈组件
V1 Moments.vue
1<template>
2 <!-- 模板内容和之前保持一致 -->
3 <div class="moments-container">
4 <!-- 头部区域 -->
5 <header class="header">
6 <div class="header-content">
7 <img
8 src="https://picsum.photos/200/200?random=1"
9 alt="个人头像"
10 class="avatar"
11 >
12 <h1 class="page-title">我的朋友圈</h1>
13 </div>
14 <button class="publish-btn" @click="showPublishModal = true">
15 <i class="icon-add">+</i> 发布
16 </button>
17 </header>
18
19 <!-- 快速发布区域 -->
20 <div class="quick-publish">
21 <img
22 src="https://picsum.photos/200/200?random=1"
23 alt="个人头像"
24 class="publish-avatar"
25 >
26 <input
27 type="text"
28 class="quick-input"
29 placeholder="分享你的想法..."
30 v-model="quickContent"
31 @keyup.enter="handleQuickPublish"
32 >
33 </div>
34
35 <!-- 动态列表 -->
36 <div class="moments-list">
37 <!-- 动态项 -->
38 <div
39 class="moment-item"
40 v-for="moment in sortedMoments"
41 :key="moment.id"
42 >
43 <div class="moment-header">
44 <img
45 src="https://picsum.photos/200/200?random=1"
46 alt="用户头像"
47 class="moment-avatar"
48 >
49 <div class="user-info">
50 <div class="username">我</div>
51 <div class="post-time">{{ formatTime(moment.time) }}</div>
52 </div>
53 </div>
54
55 <div class="moment-content" v-if="moment.content">
56 {{ moment.content }}
57 </div>
58
59 <!-- 图片网格 -->
60 <div
61 class="images-grid"
62 :class="{
63 'single': moment.images.length === 1,
64 'double': moment.images.length === 2,
65 'triple': moment.images.length === 3
66 }"
67 v-if="moment.images.length > 0"
68 >
69 <img
70 v-for="(img, index) in moment.images"
71 :key="index"
72 :src="img"
73 alt="动态图片"
74 class="grid-img"
75 @click="openImagePreview(moment.images, index)"
76 >
77 </div>
78
79 <!-- 互动按钮 -->
80 <div class="moment-actions">
81 <button
82 class="action-btn like-btn"
83 :class="{ liked: moment.liked }"
84 @click="toggleLike(moment.id)"
85 >
86 <i class="icon-like">{{ moment.liked ? '♥' : '♡' }}</i>
87 <span>{{ moment.likes }}</span>
88 </button>
89 <button class="action-btn comment-btn">
90 <i class="icon-comment">💬</i>
91 <span>{{ moment.comments }}</span>
92 </button>
93 <button class="action-btn share-btn">
94 <i class="icon-share">🔗</i>
95 <span>分享</span>
96 </button>
97 </div>
98 </div>
99
100 <!-- 空状态 -->
101 <div class="empty-state" v-if="sortedMoments.length === 0">
102 <div class="empty-icon">📝</div>
103 <p>还没有动态,发布你的第一条动态吧~</p>
104 </div>
105 </div>
106
107 <!-- 发布动态弹窗 -->
108 <div class="modal-overlay" v-if="showPublishModal" @click="closePublishModal">
109 <div class="modal" @click.stop>
110 <div class="modal-header">
111 <h2 class="modal-title">发布动态</h2>
112 <button class="close-btn" @click="closePublishModal">×</button>
113 </div>
114 <div class="modal-body">
115 <textarea
116 class="post-content"
117 placeholder="分享你的想法..."
118 v-model="postContent"
119 @input="checkPublishable"
120 ></textarea>
121
122 <div class="image-upload">
123 <div class="upload-title">添加图片(最多9张)</div>
124 <div class="preview-container">
125 <div
126 class="preview-item"
127 v-for="(img, index) in previewImages"
128 :key="index"
129 >
130 <img :src="img" alt="预览图" class="preview-img">
131 <button
132 class="remove-img"
133 @click="removeImage(index)"
134 >×</button>
135 </div>
136
137 <label class="upload-btn" v-if="previewImages.length < 9">
138 <input
139 type="file"
140 accept="image/*"
141 multiple
142 @change="handleImageUpload"
143 class="upload-input"
144 >
145 <div class="upload-icon">+</div>
146 </label>
147 </div>
148 </div>
149 </div>
150 <div class="modal-footer">
151 <button class="cancel-btn" @click="closePublishModal">取消</button>
152 <button
153 class="publish-btn"
154 @click="publishMoment"
155 :disabled="!isPublishable"
156 >
157 发布
158 </button>
159 </div>
160 </div>
161 </div>
162
163 <!-- 图片预览弹窗 -->
164 <div class="image-preview" v-if="showImagePreview" @click="closeImagePreview">
165 <div class="preview-content" @click.stop>
166 <button class="preview-close" @click="closeImagePreview">×</button>
167 <img
168 :src="currentPreviewImage"
169 alt="图片预览"
170 class="preview-img-large"
171 >
172 <div class="preview-nav" v-if="previewImagesList.length > 1">
173 <button class="nav-btn prev-btn" @click="prevImage">←</button>
174 <button class="nav-btn next-btn" @click="nextImage">→</button>
175 </div>
176 <div class="preview-counter">
177 {{ currentPreviewIndex + 1 }} / {{ previewImagesList.length }}
178 </div>
179 </div>
180 </div>
181 </div>
182</template>
183
184<script setup>
185import { ref, computed, watch, onMounted } from 'vue';
186
187// 从localStorage加载数据或使用初始数据
188const loadMoments = () => {
189 const saved = localStorage.getItem('momentsData');
190 if (saved) {
191 try {
192 return JSON.parse(saved);
193 } catch (e) {
194 console.error('Failed to parse moments data', e);
195 return [];
196 }
197 }
198 // 初始示例数据
199 return [
200 {
201 id: 1,
202 content: "今天天气真好,去公园散步了~ 🌸",
203 images: [
204 "https://picsum.photos/600/400?random=10",
205 "https://picsum.photos/600/400?random=11"
206 ],
207 time: new Date(Date.now() - 3600000).getTime(), // 1小时前
208 likes: 15,
209 liked: false,
210 comments: 3
211 },
212 {
213 id: 2,
214 content: "分享一下新做的晚餐,味道还不错!",
215 images: [
216 "https://picsum.photos/600/400?random=20"
217 ],
218 time: new Date(Date.now() - 86400000).getTime(), // 1天前
219 likes: 28,
220 liked: true,
221 comments: 7
222 },
223 {
224 id: 3,
225 content: "周末去看了一场精彩的电影,强烈推荐!",
226 images: [],
227 time: new Date(Date.now() - 172800000).getTime(), // 2天前
228 likes: 12,
229 liked: false,
230 comments: 2
231 }
232 ];
233};
234
235// 保存数据到localStorage
236const saveMoments = (data) => {
237 try {
238 localStorage.setItem('momentsData', JSON.stringify(data));
239 } catch (e) {
240 console.error('Failed to save moments data', e);
241 }
242};
243
244// 动态数据 - 从localStorage加载
245const moments = ref(loadMoments());
246
247// 监听数据变化,自动保存到localStorage
248watch(moments, (newValue) => {
249 saveMoments(newValue);
250}, { deep: true });
251
252// 组件挂载时加载数据
253onMounted(() => {
254 moments.value = loadMoments();
255});
256
257// 按日期排序(最新的在前)
258const sortedMoments = computed(() => {
259 return [...moments.value].sort((a, b) => b.time - a.time);
260});
261
262// 快速发布相关
263const quickContent = ref('');
264
265// 发布弹窗相关
266const showPublishModal = ref(false);
267const postContent = ref('');
268const previewImages = ref([]);
269const isPublishable = ref(false);
270
271// 图片预览相关
272const showImagePreview = ref(false);
273const previewImagesList = ref([]);
274const currentPreviewIndex = ref(0);
275const currentPreviewImage = computed(() => {
276 return previewImagesList.value[currentPreviewIndex.value] || '';
277});
278
279// 检查是否可以发布
280const checkPublishable = () => {
281 isPublishable.value = !!postContent.value.trim() || previewImages.value.length > 0;
282};
283
284// 格式化时间显示
285const formatTime = (timestamp) => {
286 const now = Date.now();
287 const diff = now - timestamp;
288 const second = 1000;
289 const minute = second * 60;
290 const hour = minute * 60;
291 const day = hour * 24;
292 const week = day * 7;
293
294 if (diff < minute) {
295 return '刚刚';
296 } else if (diff < hour) {
297 return `${Math.floor(diff / minute)}分钟前`;
298 } else if (diff < day) {
299 return `${Math.floor(diff / hour)}小时前`;
300 } else if (diff < week) {
301 return `${Math.floor(diff / day)}天前`;
302 } else {
303 const date = new Date(timestamp);
304 return `${date.getMonth() + 1}月${date.getDate()}日`;
305 }
306};
307
308// 切换点赞状态
309const toggleLike = (id) => {
310 const moment = moments.value.find(m => m.id === id);
311 if (moment) {
312 if (moment.liked) {
313 moment.likes--;
314 } else {
315 moment.likes++;
316 }
317 moment.liked = !moment.liked;
318 // 触发watch更新
319 moments.value = [...moments.value];
320 }
321};
322
323// 打开发布弹窗
324const openPublishModal = () => {
325 showPublishModal.value = true;
326 postContent.value = '';
327 previewImages.value = [];
328 isPublishable.value = false;
329};
330
331// 关闭发布弹窗
332const closePublishModal = () => {
333 showPublishModal.value = false;
334};
335
336// 处理图片上传
337const handleImageUpload = (e) => {
338 const files = e.target.files;
339 if (!files.length) return;
340
341 // 最多上传9张
342 const maxAdd = 9 - previewImages.value.length;
343 const filesToProcess = Array.from(files).slice(0, maxAdd);
344
345 filesToProcess.forEach(file => {
346 const reader = new FileReader();
347 reader.onload = (event) => {
348 previewImages.value.push(event.target.result);
349 checkPublishable();
350 };
351 reader.readAsDataURL(file);
352 });
353
354 // 清空input值,允许重复选择相同文件
355 e.target.value = '';
356};
357
358// 移除预览图片
359const removeImage = (index) => {
360 previewImages.value.splice(index, 1);
361 checkPublishable();
362};
363
364// 发布动态
365const publishMoment = () => {
366 const content = postContent.value.trim();
367 if (!content && previewImages.value.length === 0) return;
368
369 const newMoment = {
370 id: Date.now(),
371 content: content,
372 images: [...previewImages.value],
373 time: Date.now(),
374 likes: 0,
375 liked: false,
376 comments: 0
377 };
378
379 moments.value.unshift(newMoment);
380 closePublishModal();
381};
382
383// 快速发布
384const handleQuickPublish = () => {
385 const content = quickContent.value.trim();
386 if (!content) return;
387
388 const newMoment = {
389 id: Date.now(),
390 content: content,
391 images: [],
392 time: Date.now(),
393 likes: 0,
394 liked: false,
395 comments: 0
396 };
397
398 moments.value.unshift(newMoment);
399 quickContent.value = '';
400};
401
402// 打开图片预览
403const openImagePreview = (images, index) => {
404 previewImagesList.value = images;
405 currentPreviewIndex.value = index;
406 showImagePreview.value = true;
407 document.body.style.overflow = 'hidden';
408};
409
410// 关闭图片预览
411const closeImagePreview = () => {
412 showImagePreview.value = false;
413 document.body.style.overflow = '';
414};
415
416// 上一张图片
417const prevImage = () => {
418 currentPreviewIndex.value = (currentPreviewIndex.value - 1 + previewImagesList.value.length) % previewImagesList.value.length;
419};
420
421// 下一张图片
422const nextImage = () => {
423 currentPreviewIndex.value = (currentPreviewIndex.value + 1) % previewImagesList.value.length;
424};
425
426// 监听键盘事件用于图片预览导航
427watch(showImagePreview, (isVisible) => {
428 const handleKeydown = (e) => {
429 if (!isVisible) return;
430
431 if (e.key === 'Escape') {
432 closeImagePreview();
433 } else if (e.key === 'ArrowLeft') {
434 prevImage();
435 } else if (e.key === 'ArrowRight') {
436 nextImage();
437 }
438 };
439
440 document.addEventListener('keydown', handleKeydown);
441 return () => {
442 document.removeEventListener('keydown', handleKeydown);
443 };
444});
445</script>
446
447<style scoped>
448/* 样式和之前保持一致 */
449.moments-container {
450 max-width: 800px;
451 margin: 0 auto;
452 background-color: #fff;
453 min-height: 100vh;
454 box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
455 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
456 color: #333;
457}
458
459/* 头部样式 */
460.header {
461 padding: 16px 20px;
462 border-bottom: 1px solid #eee;
463 display: flex;
464 justify-content: space-between;
465 align-items: center;
466 background-color: #fff;
467 position: sticky;
468 top: 0;
469 z-index: 10;
470}
471
472.header-content {
473 display: flex;
474 align-items: center;
475 gap: 10px;
476}
477
478.avatar {
479 width: 40px;
480 height: 40px;
481 border-radius: 50%;
482 object-fit: cover;
483}
484
485.page-title {
486 font-size: 1.2rem;
487 font-weight: 600;
488 margin: 0;
489}
490
491.publish-btn {
492 background-color: #00c853;
493 color: white;
494 border: none;
495 padding: 8px 16px;
496 border-radius: 20px;
497 font-weight: 500;
498 cursor: pointer;
499 display: flex;
500 align-items: center;
501 gap: 6px;
502 transition: background-color 0.2s;
503}
504
505.publish-btn:hover {
506 background-color: #00b248;
507}
508
509.publish-btn:disabled {
510 background-color: #a5d6a7;
511 cursor: not-allowed;
512}
513
514/* 快速发布区域 */
515.quick-publish {
516 padding: 15px 20px;
517 border-bottom: 10px solid #f5f5f5;
518 display: flex;
519 align-items: center;
520 gap: 12px;
521}
522
523.publish-avatar {
524 width: 40px;
525 height: 40px;
526 border-radius: 50%;
527 object-fit: cover;
528}
529
530.quick-input {
531 flex: 1;
532 padding: 12px 15px;
533 border: 1px solid #e0e0e0;
534 border-radius: 25px;
535 outline: none;
536 transition: border-color 0.2s;
537 font-size: 1rem;
538}
539
540.quick-input:focus {
541 border-color: #b3e5fc;
542}
543
544/* 动态列表 */
545.moments-list {
546 padding-bottom: 20px;
547}
548
549.moment-item {
550 padding: 15px 20px;
551 border-bottom: 1px solid #eee;
552 transition: background-color 0.1s;
553}
554
555.moment-item:hover {
556 background-color: #fafafa;
557}
558
559.moment-header {
560 display: flex;
561 gap: 10px;
562 margin-bottom: 10px;
563}
564
565.moment-avatar {
566 width: 45px;
567 height: 45px;
568 border-radius: 50%;
569 object-fit: cover;
570}
571
572.user-info {
573 flex: 1;
574}
575
576.username {
577 font-weight: 600;
578 margin-bottom: 2px;
579 display: inline-block;
580}
581
582.post-time {
583 font-size: 0.8rem;
584 color: #999;
585}
586
587.moment-content {
588 margin-bottom: 10px;
589 font-size: 1rem;
590 line-height: 1.6;
591}
592
593/* 图片网格 */
594.images-grid {
595 display: grid;
596 grid-template-columns: repeat(3, 1fr);
597 gap: 5px;
598 margin-bottom: 10px;
599}
600
601.grid-img {
602 width: 100%;
603 aspect-ratio: 1/1;
604 object-fit: cover;
605 border-radius: 4px;
606 cursor: pointer;
607 transition: transform 0.2s;
608}
609
610.grid-img:hover {
611 transform: scale(1.02);
612}
613
614/* 不同图片数量的布局 */
615.images-grid.single {
616 grid-template-columns: 1fr;
617 max-width: 300px;
618}
619
620.images-grid.double {
621 grid-template-columns: repeat(2, 1fr);
622}
623
624/* 互动按钮 */
625.moment-actions {
626 display: flex;
627 justify-content: space-around;
628 padding-top: 5px;
629 border-top: 1px solid #f0f0f0;
630 margin-top: 8px;
631}
632
633.action-btn {
634 flex: 1;
635 background: none;
636 border: none;
637 padding: 8px 0;
638 color: #666;
639 font-size: 0.9rem;
640 cursor: pointer;
641 display: flex;
642 align-items: center;
643 justify-content: center;
644 gap: 5px;
645 transition: color 0.2s, background-color 0.2s;
646 border-radius: 4px;
647}
648
649.action-btn:hover {
650 background-color: #f5f5f5;
651 color: #007bff;
652}
653
654.like-btn.liked {
655 color: #e53935;
656}
657
658/* 空状态 */
659.empty-state {
660 padding: 50px 20px;
661 text-align: center;
662 color: #999;
663}
664
665.empty-icon {
666 font-size: 3rem;
667 margin-bottom: 15px;
668}
669
670/* 发布弹窗 */
671.modal-overlay {
672 position: fixed;
673 top: 0;
674 left: 0;
675 right: 0;
676 bottom: 0;
677 background-color: rgba(0, 0, 0, 0.5);
678 display: flex;
679 align-items: center;
680 justify-content: center;
681 z-index: 100;
682 opacity: 0;
683 visibility: hidden;
684 transition: opacity 0.3s, visibility 0.3s;
685}
686
687.modal-overlay:not([v-if="false"]) {
688 opacity: 1;
689 visibility: visible;
690}
691
692.modal {
693 background-color: white;
694 width: 90%;
695 max-width: 600px;
696 border-radius: 10px;
697 max-height: 90vh;
698 display: flex;
699 flex-direction: column;
700}
701
702.modal-header {
703 padding: 15px 20px;
704 border-bottom: 1px solid #eee;
705 display: flex;
706 justify-content: space-between;
707 align-items: center;
708}
709
710.modal-title {
711 font-size: 1.1rem;
712 font-weight: 600;
713 margin: 0;
714}
715
716.close-btn {
717 background: none;
718 border: none;
719 font-size: 1.5rem;
720 cursor: pointer;
721 color: #666;
722 transition: color 0.2s;
723}
724
725.close-btn:hover {
726 color: #333;
727}
728
729.modal-body {
730 padding: 20px;
731 flex: 1;
732 overflow-y: auto;
733}
734
735.post-content {
736 width: 100%;
737 min-height: 120px;
738 border: none;
739 outline: none;
740 resize: none;
741 font-size: 1rem;
742 margin-bottom: 20px;
743 line-height: 1.6;
744 font-family: inherit;
745}
746
747.image-upload {
748 margin-bottom: 20px;
749}
750
751.upload-title {
752 font-size: 0.9rem;
753 color: #666;
754 margin-bottom: 10px;
755}
756
757.preview-container {
758 display: flex;
759 flex-wrap: wrap;
760 gap: 10px;
761}
762
763.preview-item {
764 width: 80px;
765 height: 80px;
766 position: relative;
767}
768
769.preview-img {
770 width: 100%;
771 height: 100%;
772 object-fit: cover;
773 border-radius: 4px;
774}
775
776.remove-img {
777 position: absolute;
778 top: -5px;
779 right: -5px;
780 background-color: rgba(255, 255, 255, 0.9);
781 border-radius: 50%;
782 width: 20px;
783 height: 20px;
784 display: flex;
785 align-items: center;
786 justify-content: center;
787 font-size: 12px;
788 cursor: pointer;
789 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
790 border: none;
791 transition: background-color 0.2s;
792}
793
794.remove-img:hover {
795 background-color: white;
796}
797
798.upload-btn {
799 width: 80px;
800 height: 80px;
801 border: 2px dashed #ddd;
802 border-radius: 4px;
803 display: flex;
804 align-items: center;
805 justify-content: center;
806 color: #999;
807 cursor: pointer;
808 transition: border-color 0.2s, color 0.2s;
809}
810
811.upload-btn:hover {
812 border-color: #007bff;
813 color: #007bff;
814}
815
816.upload-input {
817 display: none;
818}
819
820.modal-footer {
821 padding: 15px 20px;
822 border-top: 1px solid #eee;
823 display: flex;
824 justify-content: flex-end;
825 gap: 10px;
826}
827
828.cancel-btn {
829 padding: 8px 16px;
830 border: 1px solid #ddd;
831 background: none;
832 border-radius: 4px;
833 cursor: pointer;
834 transition: background-color 0.2s;
835}
836
837.cancel-btn:hover {
838 background-color: #f5f5f5;
839}
840
841/* 图片预览弹窗 */
842.image-preview {
843 position: fixed;
844 top: 0;
845 left: 0;
846 right: 0;
847 bottom: 0;
848 background-color: rgba(0, 0, 0, 0.9);
849 z-index: 200;
850 display: flex;
851 align-items: center;
852 justify-content: center;
853 opacity: 0;
854 visibility: hidden;
855 transition: opacity 0.3s, visibility 0.3s;
856}
857
858.image-preview:not([v-if="false"]) {
859 opacity: 1;
860 visibility: visible;
861}
862
863.preview-content {
864 position: relative;
865 max-width: 90%;
866 max-height: 90vh;
867}
868
869.preview-img-large {
870 max-width: 100%;
871 max-height: 90vh;
872 object-fit: contain;
873}
874
875.preview-close {
876 position: absolute;
877 top: -40px;
878 right: 0;
879 color: white;
880 background: none;
881 border: none;
882 font-size: 1.5rem;
883 cursor: pointer;
884}
885
886.preview-nav {
887 position: absolute;
888 top: 50%;
889 width: 100%;
890 display: flex;
891 justify-content: space-between;
892 transform: translateY(-50%);
893}
894
895.nav-btn {
896 background-color: rgba(0, 0, 0, 0.3);
897 color: white;
898 border: none;
899 width: 40px;
900 height: 40px;
901 border-radius: 50%;
902 display: flex;
903 align-items: center;
904 justify-content: center;
905 cursor: pointer;
906 font-size: 1.2rem;
907 transition: background-color 0.2s;
908}
909
910.nav-btn:hover {
911 background-color: rgba(0, 0, 0, 0.5);
912}
913
914.preview-counter {
915 position: absolute;
916 bottom: -30px;
917 left: 50%;
918 transform: translateX(-50%);
919 color: white;
920 font-size: 0.9rem;
921}
922
923/* 响应式调整 */
924@media (max-width: 600px) {
925 .images-grid {
926 gap: 3px;
927 }
928
929 .action-btn span {
930 font-size: 0.8rem;
931 }
932}
933</style>创建时间:11:27
主要修改点说明:
loadMoments函数,从 localStorage 加载数据saveMoments函数,将数据保存到 localStoragewatch监听moments数据变化,自动保存到 localStorageonMounted钩子在组件挂载时加载数据这种方案适合个人使用的简单应用,数据仅保存在当前设备的浏览器中。如果需要多设备同步,就需要使用后端数据库配合 API 接口来实现数据存储了。
如上所言,只是临时解决。

日历 · 精选 · 友链 · 更多
如果内容对你有帮助,欢迎请我喝杯咖啡 ☕



One的公众号
爱折腾博客的小白