微信公眾平臺(tái)開發(fā)教程(五)詳解自定義菜單
一、概述:
如果只有輸入框,可能太簡(jiǎn)單,感覺像命令行。自定義菜單,給我們提供了很大的靈活性,更符合用戶的操作習(xí)慣。在一個(gè)小小的微信對(duì)話頁(yè)面,可以實(shí)現(xiàn)更多的功能。菜單直觀明了,不僅能提供事件響應(yīng),還支持URL跳轉(zhuǎn),如果需要的功能比較復(fù)雜,我們大可以使用URL跳轉(zhuǎn),跳轉(zhuǎn)至我們的網(wǎng)頁(yè)即可。
注意:自定義菜單,只有服務(wù)號(hào)才有此功能
接著我們?cè)敿?xì)介紹,如何實(shí)現(xiàn)自定義菜單?
二、詳細(xì)步驟:
1、首先獲取access_token
access_token是公眾號(hào)的全局唯一票據(jù),公眾號(hào)調(diào)用各接口時(shí)都需使用access_token。正常情況下access_token有效期為7200秒,重復(fù)獲取將導(dǎo)致上次獲取的access_token失效。
公眾號(hào)可以使用AppID和AppSecret調(diào)用本接口來(lái)獲取access_token。AppID和AppSecret可在開發(fā)模式中獲得(需要已經(jīng)成為開發(fā)者,且?guī)ぬ?hào)沒有異常狀態(tài))。注意調(diào)用所有微信接口時(shí)均需使用https協(xié)議。
接口調(diào)用請(qǐng)求說(shuō)明
http請(qǐng)求方式: GET
https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
參數(shù)說(shuō)明
| 參數(shù) | 是否必須 | 說(shuō)明 |
|---|---|---|
| grant_type | 是 | 獲取access_token填寫client_credential |
| appid | 是 | 第三方用戶唯一憑證 |
| secret | 是 | 第三方用戶唯一憑證密鑰,既appsecret |
返回說(shuō)明
正常情況下,微信會(huì)返回下述JSON數(shù)據(jù)包給公眾號(hào):
{"access_token":"ACCESS_TOKEN","expires_in":7200}
| 參數(shù) | 說(shuō)明 |
|---|---|
| access_token | 獲取到的憑證 |
| expires_in | 憑證有效時(shí)間,單位:秒 |
返回說(shuō)明
正常情況下,微信會(huì)返回下述JSON數(shù)據(jù)包給公眾號(hào):
{"access_token":"ACCESS_TOKEN","expires_in":7200}
| 參數(shù) | 說(shuō)明 |
|---|---|
| access_token | 獲取到的憑證 |
| expires_in | 憑證有效時(shí)間,單位:秒 |
錯(cuò)誤時(shí)微信會(huì)返回錯(cuò)誤碼等信息,JSON數(shù)據(jù)包示例如下(該示例為AppID無(wú)效錯(cuò)誤):
{"errcode":40013,"errmsg":"invalid appid"}
2、創(chuàng)建自定義菜單
自定義菜單能夠幫助公眾號(hào)豐富界面,讓用戶更好更快地理解公眾號(hào)的功能。開啟自定義菜單后,公眾號(hào)界面如圖所示:

目前自定義菜單最多包括3個(gè)一級(jí)菜單,每個(gè)一級(jí)菜單最多包含5個(gè)二級(jí)菜單。一級(jí)菜單最多4個(gè)漢字,二級(jí)菜單最多7個(gè)漢字,多出來(lái)的部分將會(huì)以“...”代替。請(qǐng)注意,創(chuàng)建自定義菜單后,由于微信客戶端緩存,需要24小時(shí)微信客戶端才會(huì)展現(xiàn)出來(lái)。建議測(cè)試時(shí)可以嘗試取消關(guān)注公眾賬號(hào)后再次關(guān)注,則可以看到創(chuàng)建后的效果。
目前自定義菜單接口可實(shí)現(xiàn)兩種類型按鈕,如下:
click:
用戶點(diǎn)擊click類型按鈕后,微信服務(wù)器會(huì)通過(guò)消息接口推送消息類型為event 的結(jié)構(gòu)給開發(fā)者(參考消息接口指南),并且?guī)习粹o中開發(fā)者填寫的key值,開發(fā)者可以通過(guò)自定義的key值與用戶進(jìn)行交互;
view:
用戶點(diǎn)擊view類型按鈕后,微信客戶端將會(huì)打開開發(fā)者在按鈕中填寫的url值 (即網(wǎng)頁(yè)鏈接),達(dá)到打開網(wǎng)頁(yè)的目的,建議與網(wǎng)頁(yè)授權(quán)獲取用戶基本信息接口結(jié)合,獲得用戶的登入個(gè)人信息。
接口調(diào)用請(qǐng)求說(shuō)明
http請(qǐng)求方式:POST(請(qǐng)使用https協(xié)議) https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN
請(qǐng)求示例
{
"button":[
{
"type":"click",
"name":"今日歌曲",
"key":"V1001_TODAY_MUSIC"
},
{
"type":"click",
"name":"歌手簡(jiǎn)介",
"key":"V1001_TODAY_SINGER"
},
{
"name":"菜單",
"sub_button":[
{
"type":"view",
"name":"搜索",
"url":"http://www.soso.com/"
},
{
"type":"view",
"name":"視頻",
"url":"http://v.qq.com/"
},
{
"type":"click",
"name":"贊一下我們",
"key":"V1001_GOOD"
}]
}]
}
參數(shù)說(shuō)明
| 參數(shù) | 是否必須 | 說(shuō)明 |
|---|---|---|
| button | 是 | 一級(jí)菜單數(shù)組,個(gè)數(shù)應(yīng)為1~3個(gè) |
| sub_button | 否 | 二級(jí)菜單數(shù)組,個(gè)數(shù)應(yīng)為1~5個(gè) |
| type | 是 | 菜單的響應(yīng)動(dòng)作類型,目前有click、view兩種類型 |
| name | 是 | 菜單標(biāo)題,不超過(guò)16個(gè)字節(jié),子菜單不超過(guò)40個(gè)字節(jié) |
| key | click類型必須 | 菜單KEY值,用于消息接口推送,不超過(guò)128字節(jié) |
| url | view類型必須 | 網(wǎng)頁(yè)鏈接,用戶點(diǎn)擊菜單可打開鏈接,不超過(guò)256字節(jié) |
返回結(jié)果
正確時(shí)的返回JSON數(shù)據(jù)包如下:
{"errcode":0,"errmsg":"ok"}
錯(cuò)誤時(shí)的返回JSON數(shù)據(jù)包如下(示例為無(wú)效菜單名長(zhǎng)度):
{"errcode":40018,"errmsg":"invalid button name size"}
3、查詢菜單
使用接口創(chuàng)建自定義菜單后,開發(fā)者還可使用接口查詢自定義菜單的結(jié)構(gòu)。
請(qǐng)求說(shuō)明
http請(qǐng)求方式:GET
https://api.weixin.qq.com/cgi-bin/menu/get?access_token=ACCESS_TOKEN
返回說(shuō)明
對(duì)應(yīng)創(chuàng)建接口,正確的Json返回結(jié)果:
{"menu":{"button":[{"type":"click","name":"今日歌曲","key":"V1001_TODAY_MUSIC","sub_button":[]},{"type":"click","name":"歌手簡(jiǎn)介","key":"V1001_TODAY_SINGER","sub_button":[]},{"name":"菜單","sub_button":[{"type":"view","name":"搜索","url":"http://www.soso.com/","sub_button":[]},{"type":"view","name":"視頻","url":"http://v.qq.com/","sub_button":[]},{"type":"click","name":"贊一下我們","key":"V1001_GOOD","sub_button":[]}]}]}}
4、刪除菜單
使用接口創(chuàng)建自定義菜單后,開發(fā)者還可使用接口刪除當(dāng)前使用的自定義菜單。
請(qǐng)求說(shuō)明
http請(qǐng)求方式:GET
https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=ACCESS_TOKEN
返回說(shuō)明
對(duì)應(yīng)創(chuàng)建接口,正確的Json返回結(jié)果:
{"errcode":0,"errmsg":"ok"}
5、事件處理
用戶點(diǎn)擊自定義菜單后,如果菜單按鈕設(shè)置為click類型,則微信會(huì)把此次點(diǎn)擊事件推送給開發(fā)者,注意view類型(跳轉(zhuǎn)到URL)的菜單點(diǎn)擊不會(huì)上報(bào)。
推送XML數(shù)據(jù)包示例:
<xml> <ToUserName><![CDATA[toUser]]></ToUserName> <FromUserName><![CDATA[FromUser]]></FromUserName> <CreateTime>123456789</CreateTime> <MsgType><![CDATA[event]]></MsgType> <Event><![CDATA[CLICK]]></Event> <EventKey><![CDATA[EVENTKEY]]></EventKey> </xml>
參數(shù)說(shuō)明:
| 參數(shù) | 描述 |
|---|---|
| ToUserName | 開發(fā)者微信號(hào) |
| FromUserName | 發(fā)送方帳號(hào)(一個(gè)OpenID) |
| CreateTime | 消息創(chuàng)建時(shí)間 (整型) |
| MsgType | 消息類型,event |
| Event | 事件類型,CLICK |
| EventKey | 事件KEY值,與自定義菜單接口中KEY值對(duì)應(yīng) |
三、實(shí)例講解
還接著上一篇文章講。微信公眾賬號(hào)開發(fā)教程(三) 實(shí)例入門:機(jī)器人(附源碼)
我們將在上一篇文章基礎(chǔ)上,添加自定義菜單功能。
1、獲取access_token
首先需要得到AppId和AppSecret
當(dāng)你成為開發(fā)者后,自然能夠在,開發(fā)者模式,便可看到這兩個(gè)值,可以重置。
然后調(diào)用按照二.1中所示,進(jìn)行操作。
注意:access_token有過(guò)期時(shí)間,如果過(guò)期,需要重新獲取。
代碼如下:
private static DateTime GetAccessToken_Time;
/// <summary>
/// 過(guò)期時(shí)間為7200秒
/// </summary>
private static int Expires_Period = 7200;
/// <summary>
///
/// </summary>
private static string mAccessToken;
/// <summary>
///
/// </summary>
public static string AccessToken
{
get
{
//如果為空,或者過(guò)期,需要重新獲取
if (string.IsNullOrEmpty(mAccessToken) || HasExpired())
{
//獲取
mAccessToken = GetAccessToken(AppID, AppSecret);
}
return mAccessToken;
}
}
/// <summary>
///
/// </summary>
/// <param name="appId"></param>
/// <param name="appSecret"></param>
/// <returns></returns>
private static string GetAccessToken(string appId, string appSecret)
{
string url = string.Format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}", appId, appSecret);
string result = HttpUtility.GetData(url);
XDocument doc = XmlUtility.ParseJson(result, "root");
XElement root = doc.Root;
if (root != null)
{
XElement access_token = root.Element("access_token");
if (access_token != null)
{
GetAccessToken_Time = DateTime.Now;
if (root.Element("expires_in")!=null)
{
Expires_Period = int.Parse(root.Element("expires_in").Value);
}
return access_token.Value;
}
else
{
GetAccessToken_Time = DateTime.MinValue;
}
}
return null;
}
/// <summary>
/// 判斷Access_token是否過(guò)期
/// </summary>
/// <returns>bool</returns>
private static bool HasExpired()
{
if (GetAccessToken_Time != null)
{
//過(guò)期時(shí)間,允許有一定的誤差,一分鐘。獲取時(shí)間消耗
if (DateTime.Now > GetAccessToken_Time.AddSeconds(Expires_Period).AddSeconds(-60))
{
return true;
}
}
return false;
}
2、設(shè)置菜單
菜單需根據(jù)需要,按照實(shí)際要求進(jìn)行設(shè)定。
這里我們提供天氣查詢功能,將常用的城市列出來(lái),點(diǎn)擊即可查詢。
然后還提供了友情鏈接,這里提供了view類型的菜單,直接可以跳轉(zhuǎn)至URL頁(yè)面,為跳轉(zhuǎn)做個(gè)好的演示。
具體菜單如下:
{
"button": [
{
"name": "鏈接",
"sub_button": [
{
"type": "view",
"name": "搜索",
"url": "http://www.baidu.com/"
},
{
"type": "view",
"name": "視頻",
"url": "http://v.qq.com/"
},
{
"type": "click",
"name": "贊一下我們",
"key": "BTN_GOOD"
}
]
},
{
"name": "查詢天氣",
"sub_button": [
{
"type": "click",
"name": "武漢",
"key": "BTN_TQ_WUHAN"
},
{
"type": "click",
"name": "上海",
"key": "BTN_TQ_SHANGHAI"
},
{
"type": "click",
"name": "北京",
"key": "BTN_TQ_BEIJING"
}
]
},
{
"type": "click",
"name": "幫助",
"key": "BTN_HELP"
}
]
}
3、管理菜單
因?yàn)椴藛蔚淖兏鼪]有那么頻繁,因此通過(guò)txt文件來(lái)設(shè)置菜單,并通過(guò)管理界面來(lái)管理菜單。
主要的管理功能:
1)從文件加載菜單
2)創(chuàng)建菜單。即將菜單通知微信服務(wù)端,并更新至微信客戶端
3)查詢菜單。獲取當(dāng)前系統(tǒng)的菜單。
4)刪除菜單。從微信服務(wù)器刪除菜單,也可以刪除后再創(chuàng)建。
實(shí)現(xiàn)代碼如下:
public class MenuManager
{
/// <summary>
/// 菜單文件路徑
/// </summary>
private static readonly string Menu_Data_Path = System.AppDomain.CurrentDomain.BaseDirectory + "/Data/menu.txt";
/// <summary>
/// 獲取菜單
/// </summary>
public static string GetMenu()
{
string url = string.Format("https://api.weixin.qq.com/cgi-bin/menu/get?access_token={0}", Context.AccessToken);
return HttpUtility.GetData(url);
}
/// <summary>
/// 創(chuàng)建菜單
/// </summary>
public static void CreateMenu(string menu)
{
string url = string.Format("https://api.weixin.qq.com/cgi-bin/menu/create?access_token={0}", Context.AccessToken);
//string menu = FileUtility.Read(Menu_Data_Path);
HttpUtility.SendHttpRequest(url, menu);
}
/// <summary>
/// 刪除菜單
/// </summary>
public static void DeleteMenu()
{
string url = string.Format("https://api.weixin.qq.com/cgi-bin/menu/delete?access_token={0}", Context.AccessToken);
HttpUtility.GetData(url);
}
/// <summary>
/// 加載菜單
/// </summary>
/// <returns></returns>
public static string LoadMenu()
{
return FileUtility.Read(Menu_Data_Path);
}
}
4、基本方法
上面的代碼,其實(shí)我們對(duì)一些公共功能做了封裝。如進(jìn)行g(shù)et請(qǐng)求、POST提交等操作,讀取文件等。
這里我們提供進(jìn)行g(shù)et、Post提交的方法案例代碼,如果使用,建議優(yōu)化。
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Web;
namespace Yank.WeiXin.Robot.Utility
{
/// <summary>
/// Http幫助類
/// </summary>
class HttpUtility
{
/// <summary>
/// 發(fā)送請(qǐng)求
/// </summary>
/// <param name="url">Url地址</param>
/// <param name="data">數(shù)據(jù)</param>
public static string SendHttpRequest(string url, string data)
{
return SendPostHttpRequest(url,"application/x-www-form-urlencoded",data);
}
/// <summary>
///
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public static string GetData(string url)
{
return SendGetHttpRequest(url,"application/x-www-form-urlencoded");
}
/// <summary>
/// 發(fā)送請(qǐng)求
/// </summary>
/// <param name="url">Url地址</param>
/// <param name="method">方法(post或get)</param>
/// <param name="method">數(shù)據(jù)類型</param>
/// <param name="requestData">數(shù)據(jù)</param>
public static string SendPostHttpRequest(string url,string contentType,string requestData)
{
WebRequest request = (WebRequest)HttpWebRequest.Create(url);
request.Method = "POST";
byte[] postBytes = null;
request.ContentType = contentType;
postBytes = Encoding.UTF8.GetBytes(requestData);
request.ContentLength = postBytes.Length;
using (Stream outstream = request.GetRequestStream())
{
outstream.Write(postBytes, 0, postBytes.Length);
}
string result = string.Empty;
using (WebResponse response = request.GetResponse())
{
if (response != null)
{
using (Stream stream = response.GetResponseStream())
{
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
{
result = reader.ReadToEnd();
}
}
}
}
return result;
}
/// <summary>
/// 發(fā)送請(qǐng)求
/// </summary>
/// <param name="url">Url地址</param>
/// <param name="method">方法(post或get)</param>
/// <param name="method">數(shù)據(jù)類型</param>
/// <param name="requestData">數(shù)據(jù)</param>
public static string SendGetHttpRequest(string url, string contentType)
{
WebRequest request = (WebRequest)HttpWebRequest.Create(url);
request.Method = "GET";
request.ContentType = contentType;
string result = string.Empty;
using (WebResponse response = request.GetResponse())
{
if (response != null)
{
using (Stream stream = response.GetResponseStream())
{
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
{
result = reader.ReadToEnd();
}
}
}
}
return result;
}
}
}
using System;
using System.Xml.Linq;
using Newtonsoft.Json;
namespace Yank.WeiXin.Robot.Utility
{
class XmlUtility
{
/// <summary>
///
/// </summary>
/// <param name="json"></param>
/// <param name="rootName"></param>
/// <returns></returns>
public static XDocument ParseJson(string json,string rootName)
{
return JsonConvert.DeserializeXNode(json, rootName);
}
}
}
5、事件處理
設(shè)置了菜單,這下需要處理事件了。跟我們之前設(shè)計(jì)ASPX或者WinForm一樣,都要綁定按鈕的事件。這里只是通過(guò)XML消息將請(qǐng)求傳遞過(guò)來(lái)。
通過(guò)“2、設(shè)置菜單"中具體的菜單內(nèi)容,我們便已經(jīng)知道需要進(jìn)行哪些事件處理了。對(duì)于按鈕類型為view的,無(wú)須處理,它會(huì)自動(dòng)跳轉(zhuǎn)至指定url.
需要處理的點(diǎn)擊事件:
1)贊一下
2)查詢某城市的天氣,北京、上海、武漢
3)幫助
這個(gè)還要沿用上章中的事件處理器EventHandler來(lái)擴(kuò)展處理。
具體的實(shí)現(xiàn)代碼吧:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Yank.WeiXin.Robot.Messages;
namespace Yank.WeiXin.Robot.Handlers
{
class EventHandler : IHandler
{
/// <summary>
/// 請(qǐng)求的xml
/// </summary>
private string RequestXml { get; set; }
/// <summary>
/// 構(gòu)造函數(shù)
/// </summary>
/// <param name="requestXml"></param>
public EventHandler(string requestXml)
{
this.RequestXml = requestXml;
}
/// <summary>
/// 處理請(qǐng)求
/// </summary>
/// <returns></returns>
public string HandleRequest()
{
string response = string.Empty;
EventMessage em = EventMessage.LoadFromXml(RequestXml);
if (em != null)
{
switch (em.Event.ToLower())
{
case ("subscribe"):
response = SubscribeEventHandler(em);
break;
case "click":
response = ClickEventHandler(em);
break;
}
}
return response;
}
/// <summary>
/// 關(guān)注
/// </summary>
/// <param name="em"></param>
/// <returns></returns>
private string SubscribeEventHandler(EventMessage em)
{
//回復(fù)歡迎消息
TextMessage tm = new TextMessage();
tm.ToUserName = em.FromUserName;
tm.FromUserName = em.ToUserName;
tm.CreateTime = Common.GetNowTime();
tm.Content = "歡迎您關(guān)注***,我是大哥大,有事就問(wèn)我,呵呵!\n\n";
return tm.GenerateContent();
}
/// <summary>
/// 處理點(diǎn)擊事件
/// </summary>
/// <param name="em"></param>
/// <returns></returns>
private string ClickEventHandler(EventMessage em)
{
string result = string.Empty;
if (em != null && em.EventKey != null)
{
switch (em.EventKey.ToUpper())
{
case "BTN_GOOD":
result = btnGoodClick(em);
break;
case "BTN_TQ_BEIJING":
result = searchWeather("beijing", em);
break;
case "BTN_TQ_SHANGHAI":
result = searchWeather("shanghai", em);
break;
case "BTN_TQ_WUHAN":
result = searchWeather("wuhai", em);
break;
case "BTN_HELP":
result = btnHelpClick(em);
break;
}
}
return result;
}
/// <summary>
/// 贊一下
/// </summary>
/// <param name="em"></param>
/// <returns></returns>
private string btnGoodClick(EventMessage em)
{
//回復(fù)歡迎消息
TextMessage tm = new TextMessage();
tm.ToUserName = em.FromUserName;
tm.FromUserName = em.ToUserName;
tm.CreateTime = Common.GetNowTime();
tm.Content = @"謝謝您的支持!";
return tm.GenerateContent();
}
/// <summary>
/// 幫助
/// </summary>
/// <param name="em"></param>
/// <returns></returns>
private string btnHelpClick(EventMessage em)
{
//回復(fù)歡迎消息
TextMessage tm = new TextMessage();
tm.ToUserName = em.FromUserName;
tm.FromUserName = em.ToUserName;
tm.CreateTime = Common.GetNowTime();
tm.Content = @"查詢天氣,輸入tq 城市名稱\拼音\首字母";
return tm.GenerateContent();
}
/// <summary>
/// 查詢天氣
/// </summary>
/// <param name="cityName"></param>
/// <param name="em"></param>
/// <returns></returns>
private string searchWeather(string cityName, EventMessage em)
{
TextMessage tm = new TextMessage();
tm.Content = WeatherHelper.GetWeather(cityName);
//進(jìn)行發(fā)送者、接收者轉(zhuǎn)換
tm.ToUserName = em.FromUserName;
tm.FromUserName = em.ToUserName;
tm.CreateTime = Common.GetNowTime();
return tm.GenerateContent();
}
}
}
6、效果圖
終于大工告成,最后來(lái)看下效果圖吧

以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
JavaScript實(shí)現(xiàn)url參數(shù)轉(zhuǎn)成json形式
這篇文章主要介紹了JavaScript實(shí)現(xiàn)url參數(shù)轉(zhuǎn)成json形式的相關(guān)代碼,有喜歡的小伙伴可以參考下2016-09-09
JavaScript Window瀏覽器對(duì)象模型原理解析
這篇文章主要介紹了JavaScript Window瀏覽器對(duì)象模型,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-05-05
JavaScript如何監(jiān)測(cè)數(shù)組的變化
最近在造輪子的時(shí)候遇到了這么一個(gè)問(wèn)題,那就是數(shù)組在調(diào)用內(nèi)部方法的時(shí)候怎么才可以監(jiān)聽到數(shù)組發(fā)生了變化,這篇文章主要給大家介紹了關(guān)于JavaScript如何監(jiān)測(cè)數(shù)組變化的相關(guān)資料,需要的朋友可以參考下2021-07-07
JS對(duì)象屬性的檢測(cè)與獲取操作實(shí)例分析
這篇文章主要介紹了JS對(duì)象屬性的檢測(cè)與獲取操作,結(jié)合實(shí)例形式分析了JS針對(duì)ES5、ES6實(shí)現(xiàn)對(duì)象屬性的檢測(cè)與獲取常見操作技巧,需要的朋友可以參考下2020-03-03
基于JavaScript實(shí)現(xiàn)瀑布流布局(二)
這篇文章主要介紹了原生JavaScript實(shí)現(xiàn)瀑布流布局的相關(guān)資料,實(shí)現(xiàn)鼠標(biāo)下拉圖片自動(dòng)加載效果,和百度圖片效果類似,需要的朋友可以參考下2016-01-01
基于JavaScript實(shí)現(xiàn)雪花許愿墻特效
新的一年就要到了,你一定有很多想許下的愿望吧!今天小編就為大家?guī)?lái)了一個(gè)基于Html+CSS+JavaScript實(shí)現(xiàn)的帶雪花的許愿墻特效,需要的可以了解一下2022-01-01

