U20女足世界杯_世界杯足球先生 - fcghfw.com

头脑王者——小程序核心功能开发

头脑王者答题对战源码分析教程

接到业务需求:用微信小程序开发一个答题对战类的游戏,借此机会呢把小程序好好研究一下(小程序出来很长时间了现在才看)。前段时间超级火爆的“头脑王者”就是我最好的研究对象。在研究阶段呢,没有配置前端、设计。所以样式基本是仿照的。废话不多说,进入正题。

我们实现最核心的玩法就行:单人答题、双人pk答题、排行榜(好友/世界)。

知识储备:涉及到实时通讯,在nodejs和workman之间,选择了workman。需要过一遍微信小程序官方文档。如果有vue基础那么上手就更快了。最重要的一点:ES6语法要熟悉。

一、用户信息

在小程序内,因为多个页面都需要用户信息,所以用户信息的获取放在app.js里,做一个本地存储。小程序在启动后是有两个不同步的线程:view thread 和 appservice thread。为了解决线程不同步造成用户信息获取出现的问题,用promise+callback进行了改造。获取用户openid的过程我们放在了第一步。获取用户信息:要判断是否有缓存、是否有授权等情况。

1 const promise = new Promise(function (resolve, reject) {

2 var openid = wx.getStorageSync('openid');

3 if (openid == "" || openid == null) {

4 wx.login({

5 success: res => {

6 wx.request({

7 url: 'https://fotonpickup.risingad.com/wxapi2.php',

8 data: { 'code': res.code },

9 success(res) {

10 wx.setStorage({

11 key: "openid",

12 data: res.data.openid

13 });

14 resolve(res.data.openid);

15 },

16 fail(res) {

17 reject(res);

18 }

19 });

20 }

21 })

22 } else {

23 resolve(openid);

24 }

25 });

26 promise.then((openid) => {

27 return new Promise((resolve, reject) => {

28 //先从本地取用户信息

29 var wxinfo = wx.getStorageSync('wxinfo');

30 if (wxinfo) {

31 resolve(wxinfo);

32 } else {

33 wx.getSetting({

34 success: res => {

35 if (res.authSetting['scope.userInfo']) {

36 // 已经授权,可以直接调用 getUserInfo 获取头像昵称,不会弹框

37 wx.getUserInfo({

38 success: res => {

39 resolve(res.userInfo);

40 }

41 })

42 } else {

43 //没有授权的情况,不知道正式版本是什么样子的。

44 wx.getUserInfo({

45 success: res => {

46 resolve(res.userInfo);

47 },fail:res=>{

48 //resolve(false);

49 //没有授权的预览版本,用open-type获取,并跳转统一页面,其他页面默认为有用户授权的状态

50 wx.navigateTo({

51 url: '/pages/scope/scope'

52 });

53 }

54 })

55 }

56 }

57 })

58 }

59 }).then((wxinfo) => {

60 if (wxinfo) {

61 //将用户信息上传服务器保存一份

62 this.uploadUserInfo(wxinfo);

63 //本地存储用户cookie

64 wx.setStorage({

65 key: "wxinfo",

66 data: wxinfo

67 });

68 }

69 this.globalData.userInfo=wxinfo;

70 if(this.userInfoReadyCallback){

71 this.userInfoReadyCallback(wxinfo);

72 }

73 })

74 })

75 },

View Code

注意看,第74行。此处注入了一个回调函数。是为了解决view thread 和app thread不同步的时候出现的问题。我们看一下怎么使用app中获取的用户信息

1 onLoad: function () {

2 wx.showLoading({

3 title: '加载中',

4 });

5 if (app.globalData.userInfo) {

6 this.setData({

7 userInfo: app.globalData.userInfo

8 });

9 wx.hideLoading();

10

11 } else {

12 app.userInfoReadyCallback = res => {

13 if (res) {

14 this.setData({

15 userInfo: res

16 });

17 wx.hideLoading();

18 }

19 };

20 }

21

22 }

View Code

注意:本地调试的,小程序不再提供弹框的方式授权。需要使用button组件,让用户点击弹出授权。我在获取用户信息的时候,如果没有授权,会跳转到一个专门的授权页面。

1

二、workman实时通讯

这个workman可以查看官方文档,配置起来很简单。不过需要注意的一点就是:小程序只支持wss的协议,必须是带证书的https的请求。我们需要改一下workman中的代码。进入start_gateway.php这个文件,配置下证书参数。transport改为ssl

1 // gateway 进程,这里使用Text协议,可以用telnet测试

2 // $gateway = new Gateway("tcp://0.0.0.0:8282");

3 $context = array(

4 // 更多ssl选项请参考手册 http://php.net/manual/zh/context.ssl.php

5 'ssl' => array(

6 // 请使用绝对路径

7 'local_cert' => 'E:/www/GatewayWorker-for-win/1535601_wxapp.x-nev.com.pem', // 也可以是crt文件

8 'local_pk' => 'E:/www/GatewayWorker-for-win/1535601_wxapp.x-nev.com.key',

9 'verify_peer' => false,

10 // 'allow_self_signed' => true, //如果是自签名证书需要开启此选项

11 )

12 );

13 $gateway = new Gateway("Websocket://0.0.0.0:1451",$context);

14 $gateway->transport = 'ssl';

在小程序的开发工具中要配置,wss域名:1451 。这个端口是在workman中配置,可以试任意端口。至此workman和小程序的连接打通了。

邀请好友pk,就是开一个房间(在workman中对应组的概念),然后在分享的链接中加入roomid这个参数。在用户登入之后,通过workman通知对方。

分享按钮对应的代码:

1 onShareAppMessage: function (res) {

2 if (res.from === 'button') {

3 var timestamp = new Date().getTime();

4 var r = parseInt(Math.random() * (100000 - 5000 + 1) + 5000, 10);

5 var roomid = timestamp + '' + r;

6 var s_endtime = timestamp + 600000;

7 var s_openid = wx.getStorageSync('openid');

8 return {

9 title: '来啊,狗蛋!对战吧',

10 path: '/pages/dz/dz?pkroom=' + roomid+'&endtime='+s_endtime+'&fqzid='+s_openid,

11 success: function () {

12 var datastr= {pkroom: roomid, endtime: s_endtime, fqzid: s_openid};

13 wx.setStorage({

14 key: 'pkinfo',

15 data:datastr

16 })

17 wx.navigateTo({

18 url: '/pages/dz/dz'

19 });

20 }

21

22 }

23 } else {

24 return {

25 title: '来啊,狗蛋!',

26 path: '/pages/index/index',

27 imageUrl: '/imgs/yang.jpg'

28 }

29 }

30 }

View Code

用户通过链接进入对战页面初始化代码:

1 onLoad: function (options) {

2

3 inituserinfo = new Promise(function (resolve, reject) {

4 if (app.globalData.userInfo) {

5 resolve(options);

6 } else {

7 app.userInfoReadyCallback = res => {

8 resolve(options);

9 };

10 }

11 });

12 inituserinfo.then(options => {

13 if (options.pkroom) {

14 //有参数,判断是否有效期内,判断pkinfo,对比openid

15 if (options.endtime > new Date().getTime()) {

16 //有效期内。

17 var pkinfo = wx.getStorageSync('pkinfo') || {};

18 pkinfo.endtime = options.endtime;

19 pkinfo.fqzid = options.fqzid;

20 pkinfo.pkroom = options.pkroom;

21 wx.setStorage({

22 key: 'pkinfo',

23 data: { 'pkroom': pkinfo.pkroom, 'endtime': pkinfo.endtime, 'fqzid': pkinfo.fqzid }

24 })

25 this.intime(pkinfo);

26 } else {

27 //url上房间过期

28 this.overtime();

29 }

30 } else {

31 //没有参数,则判断有无pkinfo;

32 var pkinfo = wx.getStorageSync('pkinfo');

33 console.log("没有参数:取pkinfo:" + pkinfo.endtime);

34 console.log("没有参数:当前时间:" + new Date().getTime());

35 if (pkinfo && (pkinfo.endtime > new Date().getTime())) {

36 //在有效期内,更新计时器。对比openid,加载用户信息

37 this.intime(pkinfo);

38 //开始监听

39 } else {

40 //没有,或则过期,清空cookie

41 this.overtime();

42 }

43 }

44 });

45 },

View Code

拿到url参数,我们要判断,这个房间谁是房主,谁是挑战者。 要判断房间是否在有效期内,房主是否关闭房间。因为用户进入挑战页的方式太多,不同方式对应这不同的情况。

1 //有效期内

2 intime: function (pkinfo) {

3 //在有效期内,更新计时器。对比openid,加载用户信息

4 //计算剩余时间

5 var stime = pkinfo.endtime - new Date().getTime();

6 endtime = parseInt(stime / 1000);

7 var s_openid = wx.getStorageSync('openid');

8 var userInfo = {

9 'name': app.globalData.userInfo.nickName,

10 'img': app.globalData.userInfo.avatarUrl,

11 'openid': s_openid

12 };

13 if (s_openid == pkinfo.fqzid) {

14 this.setData({

15 fqz: userInfo,

16 type: 'fqz',

17 pkroom: pkinfo.pkroom

18 })

19 } else {

20 this.setData({

21 tzz: userInfo,

22 type: 'tzz',

23 pkroom: pkinfo.pkroom

24 })

25 }

26 },

27 //房间过期

28 overtime: function () {

29 wx.showToast({

30 title: '对战房间已过期',

31 icon: 'info',

32 duration: 1000

33 });

34 wx.removeStorage({

35 key: 'pkinfo'

36 })

37 setTimeout(() => {

38 wx.redirectTo({

39 url: '/pages/index/index'

40 })

41 }, 1800);

42 },

View Code

现在最关键的地方来了:创建socket连接及监听!!!!!

小程序只允许同时创建不超过两个socket连接。在页面创建连接的时候尤其得注意,页面跳转、退出、隐藏、显示的过程中,要保护好线程。所以我们统一在onunload的时候,主动关闭socket连接。下次进来的时候根据储存到本地的房间信息重新创建连接。我封装了一个sockethelper.js,因为这个init/open的过程都是异步的,所以使用了promise封装了一下。(class、constructor是es6的语法,不清楚的可以先看一下es6的语法)

1 class webSocket {

2

3 constructor(obj = {}) {

4 this.hasConn = false

5 this.hasOpen = false

6 this.msghandle = obj;

7 this.msghandle['ping'] = function (e, _this) {

8 var data = { 'type': 'pong' };

9 _this.SendMsg(data);

10 }

11 var _this = this;

12 this.SocketTask = wx.connectSocket({

13 url: 'wss://wxapp.x-nev.com:1451',

14 header: {

15 'content-type': 'application/json'

16 },

17 method: 'post',

18 success: function (res) {

19 _this.hasConn = true;

20 },

21 fail: function (err) {

22 wx.showToast({

23 title: '网络异常!',

24 })

25 },

26 });

27 }

28 StartListen() {

29 var _this = this;

30 return new Promise((resolve, reject) => {

31

32 this.SocketTask.onOpen(function (res) {

33 console.log("page:socket:open");

34 _this.hasOpen = true;

35 resolve(res);

36 })

37 this.SocketTask.onClose(function (res) {

38 console.log("page:socket:close")

39 })

40

41 this.SocketTask.onError(function (res) {

42 console.log("page:socket:error-");

43 // reject(res);

44 })

45

46 this.SocketTask.onMessage(function (onMessage) {

47 var data = JSON.parse(onMessage.data);

48 var msgtype = data['type'];

49 if (msgtype in _this.msghandle) {

50 _this.msghandle[msgtype](data, _this);

51 }

52 })

53

54 });

55

56 }

57 SendMsg(msg, callback) {

58 if (this.hasOpen && this.hasConn) {

59 this.SocketTask.send({

60 data: JSON.stringify(msg),

61 success: (e) => { if (callback) { callback(e) } }

62 })

63 } else {

64 console.log("没有open,调用一下");

65 this.StartListen().then(() => {

66 this.SocketTask.send({

67 data: JSON.stringify(msg),

68 success: (e) => { if (callback) { callback(e) } }

69 })

70 });

71 }

72

73 }

74 }

75

76 module.exports = { webSocket };

View Code

用户进入房间后,初始化参数后,要通知对方。

1 onReady: function () {

2

3 var info = {};

4 var s_openid = wx.getStorageSync('openid');

5 info.openid = s_openid;

6 info.pkroom = this.data.pkroom;

7 if (this.data.type == "fqz") {

8 info.mark = "fqz";

9 info.name = this.data.fqz.name;

10 info.img = this.data.fqz.img;

11 } else {

12 info.mark = "tzz"

13 info.name = this.data.tzz.name;

14 info.img = this.data.tzz.img;

15 }

16 //如果从分享挑战页面进来,要注册获取用户信息的回调,注册socket

17 this.registResponse(info);

18 app.globalData.stask.SendMsg({

19 'type': 'pk',

20 'mark': info.mark,

21 'openid': info.openid,

22 'name': info.name,

23 'img': info.img,

24 'pkroom': info.pkroom

25 });

26 this.formattime();

27 t2 = setInterval(() => {

28 this.formattime();

29 }, 1000);

30 },

31 formattime: function () {

32 endtime--;

33 var m = parseInt(endtime / 60);

34 var s = parseInt(endtime % 60);

35 m = m.toString().length > 1 ? m : '0' + m;

36 s = s.toString().length > 1 ? s : '0' + s;

37 var tem = `${m}:${s}`;

38 this.setData({

39 time: tem

40 });

41 },

View Code

当双方都进入房间后。房主出发start事件,开始答题。start事件请求后台分配对应等级的题目,并通知对方题目

1 start: function () {

2 //请求对应等级的题目,

3 wx.request({

4 url: 'https://fotonpickup.risingad.com/wxxldev/index.php?c=WXSignApi&a=Question',

5 data: { 'roomid': this.data.pkroom, 'one': this.data.fqz.openid, 'two': this.data.tzz.openid },

6 success: (result) => {

7 this.setData({

8 qlist: result.data.row,

9 recordid: result.data.flag,

10 current: 0

11 });

12 //开始答题通知workman,提供题目id。跳转对战页面。

13 app.globalData.stask.SendMsg({

14 'type': 'ready',

15 'recordid': result.data.flag,

16 'uid': this.data.tzz.openid

17 });

18 }

19 });

20

21 },

View Code

双方开始答题,配置响应事件。这个就不一一列举了,上代码

1 registResponse: function (pkinfo) {

2 var obj = {

3 'pk': (res, _this) => {

4 //拿到用户信息,绑定用户头像。

5 if (res.content.length == 1) {

6 if (res.content[0].mark == "fqz") {

7 this.setData({

8 'fqz': res.content[0]

9 });

10 } else {

11 this.setData({

12 'tzz': res.content[0]

13 });

14 }

15 } else {

16 if (res.content[0].mark == "fqz") {

17 this.setData({

18 'fqz': res.content[0],

19 'tzz': res.content[1]

20 });

21 } else {

22 this.setData({

23 'fqz': res.content[1],

24 'tzz': res.content[0]

25 });

26 }

27 }

28 },

29 'ready': (res, _this) => {

30 //此处注册的监听,只有tzz可以捕获到。后端是单独推送的

31 //挑战者请求加载相应的题目,并准备好界面。

32 console.log("拉取题:" + res.id);

33 wx.request({

34 url: 'https://fotonpickup.risingad.com/wxxldev/index.php?c=WXSignApi&a=GetQuestion',

35 data: { 'id': res.id },

36 success: (result) => {

37 this.setData({

38 qlist: result.data,

39 current: 0

40 });

41 //通知服务器tzz准备好了,

42 app.globalData.stask.SendMsg({ 'type': 'start' });

43 }

44 });

45 },

46 'start': (res, _this) => {

47 //开始各自准备pk界面。

48 this.setData({

49 start: true

50 });

51 //在这里开启当前计时器。

52 console.log("1.开启计时器");

53 t1 = setInterval(() => {

54

55 if (this.data.time2 > 0) {

56 this.setData({

57 time2: this.data.time2 - 1

58 });

59 if (this.data.myself && this.data.opposite) {

60 //都已答题,如果不是最后一题,进入下一题。并积分,初始化状态

61 console.log("2.都已答题");

62 if (this.data.current == this.data.qlist.length - 1) {

63 //结束,出成绩。

64 clearInterval(t1);

65 console.log("9.结束计时器");

66 this.setData({

67 isend: true

68 });

69 this.next(true);

70 } else {

71 console.log("3.调用下一题");

72 this.next();

73 }

74 }

75 } else {

76 //时间到了,未答题。展示正确答案。进入下一题。

77 //初始化 答题状态、时间、样式。

78 console.log("4.时间到了有人未答题");

79 if (this.data.current == this.data.qlist.length - 1) {

80 //结束,出成绩。

81 clearInterval(t1);

82 this.setData({

83 isend: true

84 });

85 this.next(true);

86 console.log("10.结束计时器");

87 } else {

88 this.data.classlist[this.data.qlist[this.data.current].ropt - 1] = 'right';

89 setTimeout(() => {

90 console.log("5.时间到了有人未答题,显示正确答案,进入下一题");

91 this.next();

92 }, 1000);

93

94 }

95

96 }

97 }, 1000);

98 },

99 'dt': (res, _this) => {

100 console.log("7." + res.mark + "选择了" + res.opts);

101 var isright = res.opts == this.data.qlist[this.data.current].ropt ? true : false;

102 if (res.mark != this.data.type) {

103 this.setData({

104 opposite: true,//对方已答题。

105 oppositeright: isright,

106 oppositeopt: res.opts

107 })

108 } else {

109 this.setData({

110 myself: true,//自己已答题

111 myselfright: isright

112 });

113 if (isright) {

114 //答对

115 this.data.classlist[res.opts - 1] = 'right';

116 } else {

117 this.data.classlist[res.opts - 1] = 'error';

118 }

119 }

120 if (this.data.myself && this.data.opposite) {

121 //都已答题

122 this.data.classlist[this.data.qlist[this.data.current].ropt - 1] = 'right';

123 this.data.classlist[this.data.oppositeopt - 1] = this.data.oppositeright ? 'right' : 'error';

124 }

125 this.setData({

126 'classlist': this.data.classlist

127 });

128 },

129 'gameover': (res, _this) => {

130 wx.showToast({

131 title: '房主关闭房间',

132 icon: 'info',

133 duration: 1000

134 });

135 wx.removeStorage({

136 key: 'pkinfo',

137 complete: function (e) {

138 console.log(e);

139 }

140 });

141 setTimeout(() => {

142 wx.redirectTo({

143 url: '/pages/index/index'

144 })

145 }, 2000);

146 }

147 }

148 //开启监听

149 app.globalData.stask = new webSocket();

150 //注册监听响应事件

151 Object.assign(app.globalData.stask.msghandle, obj);

152

153 },

View Code

在上一下workman都监听的代码

1 /**

2 * 当客户端发来消息时触发

3 * @param int $client_id 连接id

4 * @param mixed $message 具体消息

5 */

6 public static function onMessage($client_id, $message)

7 {

8 // 向所有人发送

9

10 $message_data = json_decode($message, true);

11 $now = date('y-m-d H:i:s', time());

12 $filepath="E:\\www\\GatewayWorker-for-win\\msg.txt";

13 file_put_contents($filepath, "time:$now,client_id:".$client_id.':msg:'.$message.PHP_EOL, FILE_APPEND);

14 if (!$message_data) {

15 return ;

16 }

17

18 // 根据类型执行不同的业务

19 switch ($message_data['type']) {

20

21 // 客户端回应服务端的心跳

22 case 'pong':

23 return;

24 // 客户端登录 message格式: {type:login, name:xx, room_id:1} ,添加到客户端,广播给所有客户端xx进入聊天室

25 case 'login':

26 // 判断是否有房间号

27 if (!isset($message_data['room_id'])) {

28 return '';

29 }

30 // 把房间号昵称放到session中

31 $room_id = $message_data['room_id'];

32 //昵称

33 $client_name = htmlspecialchars($message_data['client_name']);

34 //头像

35 $client_img= $message_data['client_img'];

36 // 设置当前用户的sesion可直接设置。等同于 Gateway::setSession(string $client_id, array $session);

37 $_SESSION['room_id'] = $room_id;

38 $_SESSION['client_name'] = $client_name;

39 $_SESSION['client_img'] = $client_img;

40 // 转播给当前房间的所有客户端,xx进入聊天室 message {type:login, client_id:xx, name:xx}

41 $new_message = array('type'=>$message_data['type'], 'client_id'=>$client_id, 'client_name'=>htmlspecialchars($client_name), 'time'=>date('Y-m-d H:i:s'));

42 Gateway::sendToGroup($room_id, json_encode($new_message));

43 Gateway::joinGroup($client_id, $room_id);

44 // 获取房间内所有用户列表

45 $clients_list = Gateway::getClientSessionsByGroup($room_id);

46 foreach ($clients_list as $tmp_client_id=>$item) {

47 $clients_list[$tmp_client_id] = $item['client_name'];

48 }

49 // 给当前用户发送用户列表

50 $new_message['client_list'] = $clients_list;

51 Gateway::sendToCurrentClient(json_encode($new_message));

52 return;

53

54 // 客户端发言 message: {type:say, to_client_id:xx, content:xx}

55 case 'say':

56 // 非法请求

57 if (!isset($_SESSION['room_id'])) {

58 throw new \Exception("\$_SESSION['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']}");

59 }

60 $room_id = $_SESSION['room_id'];

61 $client_name = $_SESSION['client_name'];

62 $client_img = $_SESSION['client_img'];

63 // 私聊

64 if ($message_data['to_client_id'] != 'all') {

65 $new_message = array(

66 'type'=>'say',

67 'from_client_id'=>$client_id,

68 'from_client_name' =>$client_name,

69 'to_client_id'=>$message_data['to_client_id'],

70 'content'=>nl2br(htmlspecialchars($message_data['content'])),

71 'time'=>date('Y-m-d H:i:s'),

72 );

73 Gateway::sendToClient($message_data['to_client_id'], json_encode($new_message));

74 return Gateway::sendToCurrentClient(json_encode($new_message));

75 }

76

77 $new_message = array(

78 'type'=>'say',

79 'from_client_id'=>$client_id,

80 'from_client_name' =>$client_name,

81 'to_client_id'=>'all',

82 'client_img'=> $client_img ,

83 'content'=>nl2br(htmlspecialchars($message_data['content'])),

84 'time'=>date('Y-m-d H:i:s'),

85 );

86 return Gateway::sendToGroup($room_id, json_encode($new_message));

87 case 'pk':

88 //{type:pk, pkroom:xx,name:'xxx',img:'xxx'}

89 $pkroom= $message_data['pkroom'];

90 $_SESSION['pkroom']= $pkroom;

91 $_SESSION['name']= $message_data['name'];

92 $_SESSION['img']= $message_data['img'];

93 $_SESSION['mark']= $message_data['mark'];

94 $_SESSION['openid']= $message_data['openid'];

95 //绑定openid到client_id;

96 Gateway::bindUid($client_id, $message_data['openid']);

97 //开一个新房间

98 Gateway::joinGroup($client_id, $pkroom);

99 //返回当前对战房间的人员

100

101 $clients_list = Gateway::getClientSessionsByGroup($pkroom);

102 foreach ($clients_list as $tmp_client_id=>$item) {

103 $pklist[]=$item;

104 }

105 $new_message = array(

106 'type'=>'pk',

107 'content'=>$pklist

108 );

109 return Gateway::sendToGroup($pkroom, json_encode($new_message));

110 case 'dt':

111 $opts=$message_data['opts'];

112 $mark= $_SESSION['mark'];

113 $pkroom= $_SESSION['pkroom'];

114 $new_message = array(

115 'type'=>'dt',

116 'opts'=>$opts,

117 'mark'=>$mark

118 );

119 return Gateway::sendToGroup($pkroom, json_encode($new_message));

120 case 'ready':

121 $recordid= $message_data['recordid'];

122 $uid= $message_data['uid'];//tzz的uid

123 $new_message = array(

124 'type'=>'ready',

125 'id'=>$recordid

126 );

127 //只给tzz响应

128 return Gateway::sendToUid($uid, json_encode($new_message));

129 case 'start':

130 $pkroom= $_SESSION['pkroom'];

131 $new_message = array(

132 'type'=>'start'

133 );

134 return Gateway::sendToGroup($pkroom, json_encode($new_message));

135 case 'gameover':

136 $pkroom= $_SESSION['pkroom'];

137 $new_message = array(

138 'type'=>'gameover'

139 );

140 Gateway::sendToGroup($pkroom, json_encode($new_message));

141 return Gateway::ungroup($pkroom);

142 }

143 }

View Code

答题基本的思路就是这样。里面有个聊天室也是基于workman开发的,见图5,样式是模仿微信。有空会重新补充一下细节。现在上一下图。本人是后台程序,前端不太擅长,一些细节没有优化,请见谅。有问题的可以联系我:mian_wu@qq.com

2026-07-16 20:10:03


在 iPad 上拍攝截圖
手机动态壁纸软件app推荐 十大免费动态全屏壁纸app合集