LVGL日历控件和显示天气
-
利用TCP封装HTTP包请求天气信息
Linux还真是逐步熟悉中,现在才了解到Linux即没有原生的GUI,也没有应用层协议栈,所以要实现HTTP应用,必须利用TCP然后自己封装HTTP数据包。本篇即记录封装HTTP数据包,到心知天气请求天气信息的案例实现过程。
1、心知天气API说明
心知天气应该是当下国内使用很普遍的一个天气数据站点。相关注册和使用过程,这里就不再啰嗦了,不清楚的朋友可以自己到官网上查看(https://www.seniverse.com/)。本例仅测试实时天气数据获取,天气相关数据只有“状态(晴朗之类)”和“气温”,请求接口地址如下:
可以看到请求地址给的是域名,TCP连接需要直接给IP地址,所以用ping来获取其IP为“116.62.81.138”,端口自然是80。
得到IP地址后,先不着急编程,通过网络助手实验一把,具体过程是:选择TCP Client,连接对方IP和端口(116.62.81.138:80),然后将请求地址前加上方法字串“GET”,结尾还要有两个回车换行“\r\n\r\n”。初次测试时,忘记了回车换行符没有成功,加上后就好了。
封装好的数据包是:“GET https://api.thinkpage.cn/v3/weather/now.json?key=yourkey&location=tianjin&language=en&unit=c\r\n\r\n”。
2、JSON分析
请求到的数据是JSON格式,贴到Json.cn(https://www.json.cn/)的在线工具里,可以更清晰的看到其结构。可以看到请求实时数据(now.json),得到一个JSON对象,包含一个“results”引导的JSON数组,且数组只有一个元素,元素中又包含“location”、“now”和“last_update”三个JSON对象,内部还有键值对。
既然是开发Linux API的C程序,当然利用cJSON库来帮助进行数据解析了。本人使用的库是从网上搜到的一个百度网盘分享。
链接:https://pan.baidu.com/s/1DQynsdlNyIvsVXmf4W5b8Q
提取码:ww4z3、请求天气案例
具体思路就是建立TCP Client连接心知天气的Server,然后发送请求包,得到响应包,解析并打印出结果,案例比较简单做成单次的——开启即运行到底,代码如下:#include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <netinet/in.h> #include <arpa/inet.h> #include "cJSON.h" #define SERVER_IP "116.62.81.138" #define SERVER_PORT 80 #define NOW "now.json" #define DAILY "daily.json" #define API_KEY "SK0LJ8FI2TP0L-IsQ" #define CITY "tianjin" #define REQ_PACK "GET https://api.thinkpage.cn/v3/weather/%s?key=%s&location=%s&language=en&unit=c\r\n\r\n" #define N 1024 #define errlog(errmsg) do{ perror(errmsg);\ printf("----%s----%s----%d----\n", __FILE__, __func__, __LINE__);\ return -1;\ } while(0) //struct for weather data typedef struct { char id[16]; char name[32]; char country[16]; char path[64]; char timezone[32]; char tz_offset[16]; char text[16]; char code[4]; char temp[8]; char last_update[32]; } weather_t; //parse function & print weather_t data function void aita_ParseJsonNow(char *json, weather_t *w); void aita_PrintWeather(weather_t *w); int main(int argc, const char *argv[]) { int sockfd; struct sockaddr_in serveraddr; socklen_t addrlen = sizeof(serveraddr); char sendbuf[N] = ""; char recvbuf[N] = ""; weather_t weather = {0}; //create socket if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { errlog("socket error"); } //connect to server of seniverse.com serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = inet_addr(SERVER_IP); serveraddr.sin_port = htons(SERVER_PORT); if((connect(sockfd, (struct sockaddr*)&serveraddr, addrlen)) < 0) { errlog("connect error"); } //build & send request package sprintf(sendbuf, REQ_PACK, NOW, API_KEY, CITY); if(send(sockfd, sendbuf, N, 0) < 0) { errlog("send error"); } //waiting server response if(recv(sockfd, recvbuf, N, 0) < 0) { errlog("recv error"); } printf("recv: %s\n", recvbuf); //parse & print data aita_ParseJsonNow(recvbuf, &weather); aita_PrintWeather(&weather); close(sockfd); return 0; } void aita_ParseJsonNow(char *msg, weather_t *w) { cJSON *json, *ja, *jo, *josub, *item; json = cJSON_Parse(msg); //parse string to cJSON type if(json == NULL) { printf("json type cast error: %s", cJSON_GetErrorPtr()); return; } else { printf("parse now pack\n"); if((ja=cJSON_GetObjectItem(json, "results")) != NULL) { //get results array if((jo=cJSON_GetArrayItem(ja, 0)) != NULL) { //get array[0](the only item) //get location object if((josub=cJSON_GetObjectItem(jo, "location")) != NULL) { if((item=cJSON_GetObjectItem(josub, "id")) != NULL) { memcpy(w->id, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "name")) != NULL) { memcpy(w->name, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "country")) != NULL) { memcpy(w->country, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "path")) != NULL) { memcpy(w->path, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "timezone")) != NULL) { memcpy(w->timezone, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "timezone_offset")) != NULL) { memcpy(w->tz_offset, item->valuestring, strlen(item->valuestring)); } } //get now object if((josub=cJSON_GetObjectItem(jo, "now")) != NULL) { if((item=cJSON_GetObjectItem(josub, "text")) != NULL) { memcpy(w->text, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "code")) != NULL) { memcpy(w->code, item->valuestring, strlen(item->valuestring)); } if((item=cJSON_GetObjectItem(josub, "temperature")) != NULL) { memcpy(w->temp, item->valuestring, strlen(item->valuestring)); } } //get last_update object if((josub=cJSON_GetObjectItem(jo, "last_update")) != NULL) { memcpy(w->last_update, josub->valuestring, strlen(josub->valuestring)); } } } } //delete original json pack free memory cJSON_Delete(json); return; } void aita_PrintWeather(weather_t *w) { printf("id: %s\n", w->id); printf("name: %s\n", w->name); printf("country: %s\n", w->country); printf("path: %s\n", w->path); printf("timezone: %s\n", w->timezone); printf("timezone_offset: %s\n", w->tz_offset); printf("text: %s\n", w->text); printf("code: %s\n", w->code); printf("temperature: %s\n", w->temp); printf("last_update: %s\n", w->last_update); }
项目路径中建立了源文件main.c,编写上述代码,并导入cJSON.c和cJSON.h,编译命令为:“riscv64-unknown-linux-gnu-gcc main.c cJSON.c -o weather -lm”。因为cJSON会用到math库,而它需要“-lm”来动态链接。
lvgl显示图片和本地时间
1、lvgl的图片显示
lvgl框架中图片可以是一个文件也可以是一个变量(数组形式的图片码),当然文件还需要初始化lvgl对文件系统的接口,本例暂以变量形式提供。应用要显示图片,则需要引入一个图片控件,然后设置它的数据源——使用“lv_img_set_src()”函数。示例如下:
lv_obj_t * icon = lv_img_create(lv_scr_act(), NULL); /*From variable*/ lv_img_set_src(icon, &my_icon_dsc);
上述代码中“icon”是一个lvgl对象指针,通过“lv_img_create()”实例化,则对应图片控件。设置数据源时传入参数“my_icon_dsc”是lvgl中的图片描述符数据结构“lv_img_dsc_t”——本身是一个结构体类型,其定义源码如下:
//in “../lvgl/src/draw/lv_img_buf.h” typedef struct { uint32_t cf : 5; /*Color format: See `lv_img_color_format_t`*/ uint32_t always_zero : 3; /*It the upper bits of the first byte. Always zero to look like a non-printable character*/ uint32_t reserved : 2; /*Reserved to be used later*/ uint32_t w : 11; /*Width of the image map*/ uint32_t h : 11; /*Height of the image map*/ } lv_img_header_t; typedef struct { lv_img_header_t header; /**< A header describing the basics of the image*/ uint32_t data_size; /**< Size of the image in bytes*/ const uint8_t * data; /**< Pointer to the data of the image*/ } lv_img_dsc_t;
示例代码中,图片描述符变量的定义过程如下代码:
uint8_t my_icon_data[] = {0x00, 0x01, 0x02, ...}; static lv_img_dsc_t my_icon_dsc = {![8.png](/assets/uploads/files/1653270047514-8.png) .header.always_zero = 0, .header.w = 80, .header.h = 60, .data_size = 80 * 60 * LV_COLOR_DEPTH / 8, .header.cf = LV_IMG_CF_TRUE_COLOR, /*Set the color format*/ .data = my_icon_data, };
其中,枚举“LV_IMG_CF_TRUE_COLOR”是色彩格式定义,表示RGB格式。
宏“LV_COLOR_DEPTH”则定义色彩深度,它位于“lv_conf.h”,用户可以自定义。本例中设置为32,即4字节的ARGB8888格式。
2、时间获取
86板的Tina Linux可以通过C time库轻松地获得本地时间等数据。本例使用的API有:time()、localtime()、strftime()以及time_t、struct tm。3、图片和时间显示案例
本例继续使用线程管理lvgl刷新,创建1s周期的lvgl定时器,在定时器回调中获取本地时间并格式化输出。另外,系统初始时显示一个“天津”的Logo,而且初始即做一次时间获取和输出(如果不做,初始刹那label会显示默认“text”字样)。图片码通过软件“Img2Lcd”获取,软件配置方式如下图所示。图片生成的数组有72008个字节,被放置到头文件“aita_logo.h”。
/* Includes ------------------------------------------------------- */ #include "lvgl/lvgl.h" #include "lv_drivers/display/fbdev.h" #include "lv_drivers/indev/evdev.h" #include <stdio.h> #include <string.h> #include <unistd.h> #include <pthread.h> #include <time.h> #include <sys/time.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include "aita_logo.h" /* Private macro -------------------------------------------------- */ #define AITA_DISP_BUF_SIZE (128 * 1024) #define AITA_SCREEN_WIDTH 480 #define AITA_SCREEN_HEIGHT 480 #define AITA_TITLE_STRING "AITA Weather for LicheeRV with LVGL" #define SEND_PERIOD 1000 #define errlog(errmsg) do{ perror(errmsg);\ printf("----%s----%s----%d----\n", __FILE__, __func__, __LINE__);\ return;\ } while(0) /* Global variables ----------------------------------------------- */ lv_indev_t *aita_indev; //pointer of indev lv_obj_t *sys_scr; //pointer of system screen instance lv_obj_t *head_label; //pointer of title label instance lv_obj_t *main_label; //pointer of main label instance char main_label_text[32]; //main label text string for datetime lv_obj_t *logo_img; //pointer of city logo image instance lv_timer_t *sec_timer; //pointer of timer instance for tcp polling pthread_t lvgl_tid; //lvgl thread id pthread_t tcprecv_tid; //tcp receive thread id pthread_mutex_t lvgl_mutex; //mutex for lvgl tick //image descriptor for logo_img //ARGB8888 image 180*100 which code array is 'tj_logo' lv_img_dsc_t img_dsc_city = { .header.always_zero = 0, .header.w = 180, .header.h = 100, .data_size = 18000 * LV_COLOR_SIZE / 8, .header.cf = LV_IMG_CF_TRUE_COLOR, .data = tj_logo, }; /* Private function prototypes ------------------------------------ */ void aita_InitLVGL(void); void aita_CreateMainUI(void); void *thread_lvgl(void *arg); void sec_timer_cb(lv_timer_t *timer); void aita_InitTimer(void); void aita_GetTime(void); /* Private functions ---------------------------------------------- */ int main(void) { void *retval; //by author. initialize lvgl including displaybuffer, device for disp & input aita_InitLVGL(); //by author. initialize and register event device //these code must be in main(), otherwise the touch will fail. static lv_indev_drv_t indev_drv; lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; //by author. choice touchpad indev_drv.read_cb = evdev_read; //by author. input callback aita_indev = lv_indev_drv_register(&indev_drv); //by author. create the main view when the demo starts up aita_CreateMainUI(); //by author. create a timer aita_InitTimer(); //by author. create mutex for lvgl if(pthread_mutex_init(&lvgl_mutex, NULL) != 0) { errlog("initialize mutex error"); } //by author. create lvgl thread if(pthread_create(&lvgl_tid, NULL, thread_lvgl, (void *)0) != 0) { errlog("create lvgl thread error"); } //by author. wait for thread exit, this demo should never be here. pthread_join(lvgl_tid, &retval); printf("lvgl thread exit, return value: %s\n", (char *)retval); pthread_mutex_destroy(&lvgl_mutex); return 0; } /*Set in lv_conf.h as `LV_TICK_CUSTOM_SYS_TIME_EXPR`*/ uint32_t custom_tick_get(void) { static uint64_t start_ms = 0; if(start_ms == 0) { struct timeval tv_start; gettimeofday(&tv_start, NULL); start_ms = (tv_start.tv_sec * 1000000 + tv_start.tv_usec) / 1000; } struct timeval tv_now; gettimeofday(&tv_now, NULL); uint64_t now_ms; now_ms = (tv_now.tv_sec * 1000000 + tv_now.tv_usec) / 1000; uint32_t time_ms = now_ms - start_ms; return time_ms; } void aita_InitLVGL(void) { /*LittlevGL init*/ lv_init(); /*Linux frame buffer device init*/ fbdev_init(); //by author. initialize framebuffer device for display evdev_init(); //by author. initialize event device for touchpad /*A small buffer for LittlevGL to draw the screen's content*/ static lv_color_t buf[AITA_DISP_BUF_SIZE]; /*Initialize a descriptor for the buffer*/ static lv_disp_draw_buf_t disp_buf; lv_disp_draw_buf_init(&disp_buf, buf, NULL, AITA_DISP_BUF_SIZE); /*Initialize and register a display driver*/ static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &disp_buf; disp_drv.flush_cb = fbdev_flush; disp_drv.hor_res = 480; disp_drv.ver_res = 480; lv_disp_drv_register(&disp_drv); } void aita_CreateMainUI(void) { //by author. create system screen which is basic graphic level sys_scr = lv_obj_create(lv_scr_act()); lv_obj_set_size(sys_scr, AITA_SCREEN_WIDTH, AITA_SCREEN_HEIGHT); //by author. create the main title which is just a label head_label = lv_label_create(sys_scr); lv_label_set_text(head_label, AITA_TITLE_STRING); lv_obj_align(head_label, LV_ALIGN_TOP_MID, 0, 10); //by author. create the city logo image logo_img = lv_img_create(sys_scr); lv_img_set_src(logo_img, &img_dsc_city); lv_obj_align(logo_img, LV_ALIGN_TOP_LEFT, 10, 40); //by author. get local time and show string aita_GetTime(); main_label = lv_label_create(sys_scr); lv_label_set_text(main_label, main_label_text); lv_obj_align(main_label, LV_ALIGN_TOP_LEFT, 200, 40); lv_obj_set_style_text_font(main_label, &lv_font_montserrat_20, 0); } //by author. lvgl core thread function void *thread_lvgl(void *arg) { while(1) { pthread_mutex_lock(&lvgl_mutex); lv_task_handler(); pthread_mutex_unlock(&lvgl_mutex); usleep(5000); /* sleep for 5 ms */ } } //by author. sec_timer callback which refresh date string void sec_timer_cb(lv_timer_t *timer) { aita_GetTime(); lv_label_set_text(main_label, main_label_text); } //by author. initialize timer for 1s timing void aita_InitTimer(void) { sec_timer = lv_timer_create(sec_timer_cb, 1000, NULL); lv_timer_set_repeat_count(sec_timer, -1); } //by author. get local time string void aita_GetTime(void) { time_t tsec; struct tm *tlocal; tsec = time(NULL); tlocal = localtime(&tsec); memset(main_label_text, 0, 32); strftime(main_label_text, 32, "%Y-%m-%d %a %H:%M:%S", tlocal); }
lvgl日历控件和显示天气
本篇结合本人前两篇的HTTP请求天气数据(通过“心知天气”网站)和lvgl显示图片及时间,在案例主界面上增加了日历显示和实时天气显示,先直接上图。
1、lvgl日历控件
calendar是lvgl提供的“Extra widgets”组件之一,需要注意的是8.0版本后有几个API的传参发生了变化,本例使用8.3版本,设置日期是需要同时传递“年、月、日”三个参数。本例使用的API有:lv_calendar_create()、lv_canlendar_set_today_date()、lv_calendar_set_showed_date()和lv_calendar_header_arrow_create()。
lv_calendar_create()函数用于实例化calendar控件,传参是控件的父容器指针,本例使用“lv_scr_act()”即系统屏幕。
lv_canlendar_set_today_date()函数用于设置当前日期,本人使用发现lvgl是附带万年历功能的,只要设置好当天的年月日,就可以自动生成正确的日历排布。函数传参分别是控件指针和年月日数据。
关于年月日参数有两点注意事项。一是v7版本中,传参通过lv_calendar_date_t结构体,其包含年月日三个成员。二是如果使用了C time库的struct tm,注意其中年份需要加上“1900”,而月份则需要加“1”。
lv_calendar_set_showed_date()函数用于设置日历当前显示页,也就是设置当前月份。本人实验的效果是当天日期框会自动高亮,如果想设置多个高亮日期,可以使用函数lv_calendar_set_highlighted_dates()。
lv_calendar_header_arrow_create()函数用于向日历控件顶部增加“左、右箭头”两个按钮用于日历翻页(一页是一月)。此外,还有函数lv_calendar_header_dropdown_create()则是设置两个下拉列表分别用于选择年份和月份。这两个函数都只用传递日历控件指针一个参数,且是8.1版本新增API。
2、日历和天气显示案例
本案例的思路是:1)在应用启动时,获取当前时间(上篇中已经实现),然后将时间保存在全局量“struct tm today”中,并利用变量“today”来初始化日历控件的日期数据。2)上篇实现的时间显示案例,通过lvgl定时器,每秒获取本地数据,此处在定时器回调中再增加一个每到正分钟发送“Linux条件变量”。3)同时,应用启动时建立两个线程——lvgl线程和请求天气线程,请求天气线程等待条件变量到来,开启一次天气数据请求过程。本例代码结合文章上半部分已经给出的案例,这里只给出改变部分。
/* Includes ------------------------------------------------------- */ // 增加头文件,cJSON用于解析JSON格式的天气数据 #include "cJSON.h" /* Private macro -------------------------------------------------- */ // 增加请求天气数据相关的宏定义 #define HTTP_IP "116.62.81.138" #define HTTP_PORT 80 #define NOW "now.json" #define API_KEY "SK0LJ8FI2TP0L-IsQ" #define CITY "tianjin" #define REQ_PACK "GET https://api.thinkpage.cn/v3/weather/%s?key=%s&location=%s&language=en&unit=c\r\n\r\n" #define N 1024 // struct for weather data 建立结构体存储解析后的天气数据 typedef struct { char id[16]; char name[32]; char country[16]; char path[64]; char timezone[32]; char tz_offset[16]; char text[16]; char code[4]; char temp[8]; char last_update[32]; } weather_t; /* Global variables ----------------------------------------------- */ // 增加显示天气的标签控件定义 lv_obj_t *weather_label; //pointer of weather label instance // 增加日历控件定义 lv_obj_t *calendar; //pointer of calendar instance // 定义today变量存储当前日期,用于设置日历 struct tm today; // // 请求天气的线程ID pthread_t reqweather_tid; //request weather thread id // 请求天气线程等待的条件变量(min_cond) // Linux中需要互斥量包含条件变量的使用,所以定义cond_mutex pthread_mutex_t cond_mutex; //mutex for 1-min cond pthread_cond_t min_cond; //1-min cond /* Private functions ---------------------------------------------- */ int main(void) { // other code from previous demo // main()函数中创建互斥量、条件变量、请求天气线程 //by author. create mutex for 1-min cond if(pthread_mutex_init(&cond_mutex, NULL) != 0) { errlog("initialize cond mutex error"); } //by author. create condition for 1-min if(pthread_cond_init(&min_cond, NULL) != 0) { errlog("initialize 1 minute condition error"); } //by author. create request weather thread if(pthread_create(&reqweather_tid, NULL, thread_reqweather, (void *)0) != 0) { errlog("create request weather thread error"); } //by author. wait for thread exit, this demo should never be here. pthread_join(lvgl_tid, &retval); printf("lvgl thread exit, return value: %s\n", (char *)retval); pthread_join(reqweather_tid, &retval); printf("request weather thread exit, return value: %s\n", (char *)retval); pthread_mutex_destroy(&lvgl_mutex); pthread_mutex_destroy(&cond_mutex); pthread_cond_destroy(&min_cond); return 0; } void aita_CreateMainUI(void) { // other code from previous demo // aita_CreateMainUI()被main()函数调用,初始化主界面。 //by author. create the weather label weather_label = lv_label_create(sys_scr); lv_label_set_text(weather_label, " "); lv_obj_align(weather_label, LV_ALIGN_TOP_LEFT, 200, 120); //by author. create the calendar calendar = lv_calendar_create(sys_scr); lv_obj_set_size(calendar, 235, 235); lv_obj_align(calendar, LV_ALIGN_BOTTOM_LEFT, 10, -50); lv_calendar_set_today_date(calendar, today.tm_year+1900, today.tm_mon+1, today.tm_mday); lv_calendar_set_showed_date(calendar, today.tm_year+1900, today.tm_mon+1); lv_calendar_header_arrow_create(calendar); } // 增加正分钟发送条件变量 void sec_timer_cb(lv_timer_t *timer) { aita_GetTime(); lv_label_set_text(main_label, main_label_text); if(today.tm_sec == 0) { //by author. send condition signal per whole minute pthread_cond_signal(&min_cond); } } // 增加对today的赋值 void aita_GetTime(void) { time_t tsec; struct tm *tlocal; tsec = time(NULL); tlocal = localtime(&tsec); today = *tlocal; memset(main_label_text, 0, 32); strftime(main_label_text, 32, "%Y-%m-%d %a %H:%M:%S", tlocal); } // 请求天气线程业务逻辑 void *thread_reqweather(void *arg) { int sockfd; struct sockaddr_in serveraddr; socklen_t addrlen = sizeof(serveraddr); char sendbuf[N] = ""; char recvbuf[N] = ""; weather_t weather = {0}; char w_string[64] = ""; while(1) { pthread_mutex_lock(&cond_mutex); pthread_cond_wait(&min_cond, &cond_mutex); pthread_mutex_unlock(&cond_mutex); //create socket if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { errlog("socket error"); } //connect to server of seniverse.com serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = inet_addr(HTTP_IP); serveraddr.sin_port = htons(HTTP_PORT); if((connect(sockfd, (struct sockaddr*)&serveraddr, addrlen)) < 0) { errlog("connect error"); } //build & send request package memset(sendbuf, 0, N); sprintf(sendbuf, REQ_PACK, NOW, API_KEY, CITY); if(send(sockfd, sendbuf, N, 0) < 0) { errlog("send error"); } //waiting server response if(recv(sockfd, recvbuf, N, 0) < 0) { errlog("recv error"); } printf("recv: %s\n", recvbuf); //parse & print data,下面两个函数来自于“十三”案例 aita_ParseJsonNow(recvbuf, &weather); aita_PrintWeather(&weather); close(sockfd); memset(recvbuf, 0, N); //show weather string memset(w_string, 0, 64); sprintf(w_string, "weather:%s temperatur:%s", weather.text, weather.temp); pthread_mutex_lock(&lvgl_mutex); lv_label_set_text(weather_label, w_string); pthread_mutex_unlock(&lvgl_mutex); } }
另外,本例在lvgl工程中增加了cJSON.c和cJSON.h文件,Makefile也做出了调整,具体如下所示。
原文链接:https://occ.t-head.cn/community/post/detail?spm=a2cl5.25411629.0.0.6597180fVP7giT&id=4039525135236603904
作者 @ firr
Copyright © 2024 深圳全志在线有限公司 粤ICP备2021084185号 粤公网安备44030502007680号