AiC-MCU 驱动教程系列——CDC驱动虚拟OLED实现多级菜单
一、前言最近开源了一个科学计算器的项目,有小伙伴对其中的多级菜单比较感兴趣,今天这里就专门对这个多级菜单进行下讲解,刚好接上前面讲过的虚拟OLED驱动教程(能在显示屏上显示任意的汉字和字符),详情请参考本论坛的《AiC-MCU驱动教程系列——CDC驱动虚拟OLED显示汉字字符》一文,本篇在此基础上对代码进行改写。本菜单改编自开源的傻孩子菜单框架,有兴趣的朋友可以自行搜索该菜单相关的知识,本人基于该菜单稍稍增加了一些自己的功能,改编出了符合自己项目需要的一个菜单框架,完整的菜单使用案例参考屠龙刀或者开天斧代码包里的科学计算器的应用,本文主要基于该菜单,配合 8H8K64U的核心板做了一个独立的应用,方便大家快速入手,并帮助大家掌握一些常见的C语言语法和一些较好的程序框架。二、多级菜单的意义
我们在做项目的时候,经常会用到如上图这种多级菜单的应用,一个主菜单下,可以分成多个的子菜单,每个子菜单又可以进入下一级的子菜单,子菜单还可以返回上一级菜单,且最末尾的一级菜单进入之后还可以实现一些数据的查看或者参数的设置。尤其是在各种参数或者功能非常多的项目里,这种多级菜单显得尤其吃香。尤其时他的扩展性非常的好,只要内存足够,理论可以无限扩展这个菜单,界面数量不限,内容也可以高度的自定义。例如我做的科学计算器的项目就用到了一个多级菜单,该项目用到的完整的菜单框架如下。
可以看到科学计算器作为一个主菜单,在他该菜单下面包含了“普通计算”、“复数计算”等八个菜单选项,我们把这八个菜单选项都称为一级菜单,因为一进去就会显示该菜单。可以看到其中“矩阵计算”的菜单选项下又有九个菜单选项,这些菜单选项都叫做二级菜单,其中“计算行列式”这个二级菜单选项之下还有三个菜单选项,这三个就属于三级菜单,几级菜单以此类推。像这种包含多个界面还有多个深度的菜单,我们称之为多级菜单,也叫树形结构菜单。当然这种菜单应用非常广泛,不单单是我们C语言或者单片机才能用,例如STC官网的这个也是一个多级菜单的点典型应用,由此可见一个好的多级菜单的重要性。
三、单片机多级菜单的实现
3.1需求分析
我们要实现的菜单必须要有如下的功能:(1)每一页的菜单的需要有一个标题,包含数字或者文本,那就需要定义一个字符数组;(2)有些菜单下需要实现一些特定的功能,例如参数显示,文字提示,IO口操作或者实现用户功能的功能等等,这里可以通过建立一个函数指针指向一个用户的功能函数来实现;(3)每一级菜单会有很多的菜单选项,那么具体有多少个菜单选项我们需要给他定义一个数量,防止他无限的扩展下去,考虑到我们菜单最多也不超过十个,这里定义一个8位变量够了(4)菜单需要进入下一级菜单,那么我们需要有一个指针可以指向下一级的菜单,当然没有下一级菜单我们也可以给他留空。(5)既然菜单能进入下一级菜单,那么必然我们还需要让它能返回上一级菜单,那么我们还要一个指针可以指向上一级菜单,当然没有上级菜单我们可以也留空。
3.2功能整合和参数分析
综上所述,每一个菜单选项需要包含如上信息,且数据的类型包含字符数组、函数指针、单字节变量和两个地址的指针,那么我们最好就是用结构体struct类型去定义一个菜单选项。<font face="宋体" size="3">struct MenuItem //单个菜单选项的结构体
{
char MenuCount; //结构体数组的元素个数
unsigned char DisplayString;//当前LCD显示的信息
void (*Subs)(); //执行的函数的指针.
struct MenuItem *Childrenms; //指向子节点的指针
struct MenuItem *Parentms; //指向父节点的指针
}; </font>
最终我们结合3.1需求分析得出的菜单选项的结构体如上所示:(1)参数MenuCount表示这个菜单选项所在的菜单一共有几个选项,且这个菜单选项所在的这个目录里的所有菜单选项的MenuCount都是这个数值。因为他们属于同一级菜单,同一级菜单里的总数必然是确定的。例如下面这个,“菜单1”这个菜单下面包含“菜单1-1”,“菜单1-2”,“菜单1-3”三个子菜单,那么这三个子菜单的MenuCount的值都是3。 (2)参数DisplayString作为一个数组,他定义了我们每一个菜单选项的名字最长为20个字节(在GBK编码下,一个汉字是两个字节,一个字符1个字节),所以最多可以保存10个汉字或者20个字符作为我们这个菜单选项的标题(这里管他叫做Title),当然汉字和字符共存也是没问题的。例如“菜单1-1”这个菜单选项的标题就可以叫“菜单1-1”,它包含了2*2+3个字节,完全可以保存的下。当然我们这个菜单的的名字都是可以自己取的,只要自己能够方便辨认即可。 (3)void (*Subs)();这里直接定义了一个函数指针。他可以指向任意一个入口参数的为void的用户函数,我们可以把我们每个终端菜单选项(终端菜单选项表示该菜单选项下没有子菜单)下要做的功能都单独的去封装成一个函数,例如我们一级菜单是“LED功能测试”,二级终端菜单就分为“LED呼吸测试”,“LED闪烁测试”,“LED流水灯测试”,这三个二级终端菜单选项每个我们都可以给他写一个单独的写一个函数,如下所示:void LED_Breathe(void)
{
...
}
void LED_Blink(void)
{
...
}
void LED_WaterLamp(void)
{
...
}这样我们可以让每个函数实现对应的功能,这就是我们一个模块化编程的思路。执行到这个菜单的时候,把这个菜单的功能函数地址传到一个全局的函数指针,例如像如下所示的定义一个:void(*Func)(void);假如需要通过该函数指针实现一个LED闪烁的功能,那么我们可以直接给这个函数进行如下传参Func =LED_Blink;然后就可以在相应的地方进行如下的调用Func();就可以实现LED闪烁的功能了,当然这个函数既可以在主函数里执行,也可以在中断函数执行,看个人需求。当然,如果不希望这个函数执行任何的操作的话,也可以直接把这个函数指针指向一个空函数就好了,例如定义如下一个Nop的空函数:void Nop(void)
{}在初始化前将Func指向为Nop,或者不需要Func动作的时候指向为Nop即可,这样非常的方便灵活,且扩展菜单或者修改菜单的时候进行别的操作。当然如果不用上面的函数指针,当然也可以判断当前是在执行哪个菜单选项,然后手动指定要实现什么函数,例如:if( 当前菜单 == “LED闪烁” )
LED_Blink();
else if( 当前菜单 == “LED呼吸” )
LED_Breathe();
else if( 当前菜单 == “LED流水灯” )
LED_WaterLamp();可以明显的看到这样程序就会显得比较臃肿,且不够直观,修改起来也是难度非常的大。 (4)最后两个参数都是struct MenuItem *类型,他们作为一个指针分别指向子菜单(下一级菜单)或者父菜单(上一级菜单),当然,如果子菜单或者父菜单为空,这里也可以直接给他写入为空指针,相应的如果程序判别到了这已经是最后一个节点了就不需要执行进入菜单或者返回菜单的选项了。
3.3 菜单切换首先既然要实现菜单的动态切换,尤其是菜单选项的类型是个结构体数组,那么我们必然要定义一个结构体指针struct MenuItem *Index; //菜单导航其次,我们在操作菜单的过程中,还需要我们控制我们当前选中的菜单,例如下图中,我们这一级菜单包含五个选项,我们这里显示一个">"符号提示我们当前选择了第几个菜单,当然也给每一个菜单选项定义一个编号,然后通过数字键盘按下相对应的按钮进入该菜单这里我们选用一个菜单需要的变量index_addr,因为我们菜单数量比较少,所以这里只定义了一个8位的无符号变量。<font face="Tahoma" size="2">u8 index_addr; //这一几菜单的第几个菜单选项</font>最后,由于菜单还需要执行一些特定的功能函数,所以我们需要定义一个变量指示我们当前是在菜单切换还是在进行功能执行:u8 FuncMenu_Flag; //0:菜单切换功能 1:执行用户函数定义好了上面的三个变量,我们就可以将我们的菜单向上翻,向下翻,进入子菜单,返回父菜单,显示当前菜单的功能函数给写出来了,这五个函数的具体写法如下:
//========================================================================
// 函数: void Menu_Next(void)
// 描述: 选择下一个菜单选项
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Next(void)
{
if(index_addr < (Index.MenuCount-1))
index_addr++;
Menu_Show(); //显示菜单
}
//========================================================================
// 函数: void Menu_Last(void)
// 描述: 选择上一个菜单选项
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Last(void)
{
if(index_addr>0)
index_addr--;
Menu_Show(); //显示菜单
}
//========================================================================
// 函数: void Menu_Enter(void)
// 描述: 有子菜单的话进入子菜单,没子菜单的话显示当前菜单名字和执行功能函数
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Enter(void)
{
if( Index.Childrenms != Null )
{
Index = Index.Childrenms;
index_addr = 0; //菜单选项的当前位置
Menu_Show();
}
else
{
FuncMenu_Flag = 1;
Menu_ShowTitle();
Func = Index.Subs;
}
}
//========================================================================
// 函数: void Menu_Back(void)
// 描述: 返回上级菜单
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Back(void)
{
if( FuncMenu_Flag==0 )
{
if( Index.Parentms != Null )
Index = Index.Parentms;
index_addr = 0;
Menu_Show();
}
else
{
Func = Nop;
Menu_Show();
FuncMenu_Flag = 0;
}
}
//========================================================================
// 函数: Void Menu_Show(void)
// 描述: 菜单界面
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Show(void)
{
u8 xdata menu_num = Index.MenuCount; //计算当级菜单的总数量
u8 xdata page =index_addr/4; //计算当前菜单的页数
u8 xdata num = (menu_num-4*page)<4?(menu_num-4*page):4; //计算当前菜单的总选项数量
u8 xdata i;
OLED_BuffClear();
for( i=0;i<num;i++)
{
OLED_BuffShowString(8,i*2,Index.DisplayString);
}
OLED_BuffShowChar(0,(index_addr%4)*2,'>');
OLED_BuffShow();
}
3.4项目实例我们还是以开天斧板子为例,编写一个文章开头的那个多级菜单的意义里的第一个图片所展示的界面,实现我们今天主要讲解的菜单间的跳转。主要的菜单界面按如下的定义:struct MenuItem xdata Menu_ONE_ONE;
struct MenuItem xdata Menu_TWO_TWO;
struct MenuItem xdata Menu_ONE;
struct MenuItem xdata Menu_TWO;
struct MenuItem xdata Menu_THREE;
struct MenuItem xdata Menu_Main;
void Nop(void)
{}
struct MenuItem xdata Menu_ONE_ONE=
{
{2,"菜单1-1-1",LED_Bilnk_P20,Null,Menu_ONE},
{2,"菜单1-1-2",LED_Bilnk_P21,Null,Menu_ONE},
};
struct MenuItem xdata Menu_TWO_TWO=
{
{2,"菜单2-2-1",Nop,Null,Menu_TWO},
{2,"菜单2-2-2",Nop,Null,Menu_TWO},
};
struct MenuItem xdata Menu_ONE=
{
{3,"菜单1-1",Nop,Menu_ONE_ONE,Menu_Main},
{3,"菜单1-2",Nop,Null,Menu_Main},
{3,"菜单1-3",Nop,Null,Menu_Main},
};
struct MenuItem xdata Menu_TWO=
{
{2,"菜单2-1",Nop,Null,Menu_Main},
{2,"菜单2-2",Nop,Menu_TWO_TWO,Menu_Main},
};
struct MenuItem xdata Menu_THREE=
{
{5,"菜单3-1",Nop,Null,Menu_Main},
{5,"菜单3-2",Nop,Null,Menu_Main},
{5,"菜单3-3",Nop,Null,Menu_Main},
{5,"菜单3-4",Nop,Null,Menu_Main},
{5,"菜单3-5",Nop,Null,Menu_Main},
};
struct MenuItem xdata Menu_Main= //主菜单
{
{3,"菜单1",Nop,Menu_ONE,Null},
{3,"菜单2",Nop,Menu_TWO,Null},
{3,"菜单3",Nop,Menu_THREE,Null},
};
当然定义了所有的菜单选项之后,一定要对结构体指针和相关变量进行初始化,例如://========================================================================
// 函数: void Menu_Init(void)
// 描述: 菜单初始化
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Init(void)
{
index_addr = 0; //菜单选项的当前位置
Index = Menu_Main; //初始的菜单选项指向主菜单界面
Menu_Show(); //显示菜单
FuncMenu_Flag = 0; //默认执行函数
Func =Nop;
}
当然这里为了方便演示用户功能函数的执行,单独加了几个用户功能函数进行测试,具体的如下所示:
//========================================================================
// 函数: void Menu_ShowTitle(void)
// 描述: 显示当前菜单的标题
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_ShowTitle(void)
{
OLED_BuffClear();
OLED_BuffShowString(0,0,"当前:");
OLED_BuffShowString(40,0,Index.DisplayString);
OLED_BuffShow();
}
//------------------------------------------测试用的函数------------------------------------------
void LED_Bilnk_P20(void) //P20的LED闪烁
{
static u16 count=0;
if( count==0 )
{
P20=!P20;
OLED_ShowString(0,2,"LED_P20_Blink");
}
count ++;
count %= 50;
}
void LED_Bilnk_P21(void) //P21的LED闪烁
{
static u16 count=0;
if( count==0 )
{
P21=!P21;
OLED_ShowString(0,2,"LED_P21_Blink");
}
count ++;
count %= 50;
}
这样我们的测试代码就写的差不多了。现在,我们就可以将本次的菜单的界面单独的封装成一个菜单文件,完整的menu.h文件如下所示:#ifndef __MENU_H
#define __MENU_H
#include "../comm/STC8h.h"//包含此头文件后,不需要再包含"reg51.h"头文件
#include "../comm/usb.h" //USB调试及复位所需头文件
#define Null ((void *)0)
struct MenuItem //单个菜单选项的结构体
{
char MenuCount; //结构体数组的元素个数
unsigned char DisplayString;//当前LCD显示的信息
void (*Subs)(); //执行的函数的指针.
struct MenuItem *Childrenms; //指向子节点的指针
struct MenuItem *Parentms; //指向父节点的指针
};
extern struct MenuItem *Index;
extern u8 index_addr;
extern void(*Func)(void);
void Nop(void) ;
void Menu_Init(void);
void Menu_Next(void) ;
void Menu_Last(void) ;
void Menu_Show(void) ;
void Menu_Enter(void) ;
void Menu_Back(void) ;
void Menu_ShowTitle(void);
void LED_Bilnk_P20(void); //P20的LED闪烁
void LED_Bilnk_P21(void); //P21的LED闪烁
#endif
完整的menu.c文件如下所示:#include "menu.h"
#include "oled.h"
struct MenuItem*Index; //当前的结构体指针
u8 index_addr; //这一几菜单的第几个菜单选项
u8 FuncMenu_Flag; //0:菜单切换功能 1:执行用户函数
void(*Func)(void);
struct MenuItem xdata Menu_ONE_ONE;
struct MenuItem xdata Menu_TWO_TWO;
struct MenuItem xdata Menu_ONE;
struct MenuItem xdata Menu_TWO;
struct MenuItem xdata Menu_THREE;
struct MenuItem xdata Menu_Main;
void Nop(void)
{}
struct MenuItem xdata Menu_ONE_ONE=
{
{2,"菜单1-1-1",LED_Bilnk_P20,Null,Menu_ONE},
{2,"菜单1-1-2",LED_Bilnk_P21,Null,Menu_ONE},
};
struct MenuItem xdata Menu_TWO_TWO=
{
{2,"菜单2-2-1",Nop,Null,Menu_TWO},
{2,"菜单2-2-2",Nop,Null,Menu_TWO},
};
struct MenuItem xdata Menu_ONE=
{
{3,"菜单1-1",Nop,Menu_ONE_ONE,Menu_Main},
{3,"菜单1-2",Nop,Null,Menu_Main},
{3,"菜单1-3",Nop,Null,Menu_Main},
};
struct MenuItem xdata Menu_TWO=
{
{2,"菜单2-1",Nop,Null,Menu_Main},
{2,"菜单2-2",Nop,Menu_TWO_TWO,Menu_Main},
};
struct MenuItem xdata Menu_THREE=
{
{5,"菜单3-1",Nop,Null,Menu_Main},
{5,"菜单3-2",Nop,Null,Menu_Main},
{5,"菜单3-3",Nop,Null,Menu_Main},
{5,"菜单3-4",Nop,Null,Menu_Main},
{5,"菜单3-5",Nop,Null,Menu_Main},
};
struct MenuItem xdata Menu_Main= //主菜单
{
{3,"菜单1",Nop,Menu_ONE,Null},
{3,"菜单2",Nop,Menu_TWO,Null},
{3,"菜单3",Nop,Menu_THREE,Null},
};
//========================================================================
// 函数: void Menu_Init(void)
// 描述: 菜单初始化
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Init(void)
{
index_addr = 0; //菜单选项的当前位置
Index = Menu_Main; //初始的菜单选项指向主菜单界面
Menu_Show(); //显示菜单
FuncMenu_Flag = 0; //默认执行函数
Func =Nop;
}
//========================================================================
// 函数: void Menu_Next(void)
// 描述: 选择下一个菜单选项
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Next(void)
{
if(index_addr < (Index.MenuCount-1))
index_addr++;
Menu_Show(); //显示菜单
}
//========================================================================
// 函数: void Menu_Last(void)
// 描述: 选择上一个菜单选项
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Last(void)
{
if(index_addr>0)
index_addr--;
Menu_Show(); //显示菜单
}
//========================================================================
// 函数: void Menu_Enter(void)
// 描述: 有子菜单的话进入子菜单,没子菜单的话显示当前菜单名字和执行功能函数
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Enter(void)
{
if( Index.Childrenms != Null )
{
Index = Index.Childrenms;
index_addr = 0; //菜单选项的当前位置
Menu_Show();
}
else
{
FuncMenu_Flag = 1;
Menu_ShowTitle();
Func = Index.Subs;
}
}
//========================================================================
// 函数: void Menu_Back(void)
// 描述: 返回上级菜单
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Back(void)
{
if( FuncMenu_Flag==0 )
{
if( Index.Parentms != Null )
Index = Index.Parentms;
index_addr = 0;
Menu_Show();
}
else
{
Func = Nop;
Menu_Show();
FuncMenu_Flag = 0;
}
}
//========================================================================
// 函数: Void Menu_Show(void)
// 描述: 菜单界面
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_Show(void)
{
u8 xdata menu_num = Index.MenuCount; //计算当级菜单的总数量
u8 xdata page =index_addr/4; //计算当前菜单的页数
u8 xdata num = (menu_num-4*page)<4?(menu_num-4*page):4; //计算当前菜单的总选项数量
u8 xdata i;
OLED_BuffClear();
for( i=0;i<num;i++)
{
OLED_BuffShowString(8,i*2,Index.DisplayString);
}
OLED_BuffShowChar(0,(index_addr%4)*2,'>');
OLED_BuffShow();
}
//========================================================================
// 函数: void Menu_ShowTitle(void)
// 描述: 显示当前菜单的标题
// 参数: none.
// 返回: none.
// 其他:无
//========================================================================
void Menu_ShowTitle(void)
{
OLED_BuffClear();
OLED_BuffShowString(0,0,"当前:");
OLED_BuffShowString(40,0,Index.DisplayString);
OLED_BuffShow();
}
//------------------------------------------测试用的函数------------------------------------------
void LED_Bilnk_P20(void) //P20的LED闪烁
{
static u16 count=0;
if( count==0 )
{
P20=!P20;
OLED_ShowString(0,2,"LED_P20_Blink");
}
count ++;
count %= 50;
}
void LED_Bilnk_P21(void) //P21的LED闪烁
{
static u16 count=0;
if( count==0 )
{
P21=!P21;
OLED_ShowString(0,2,"LED_P21_Blink");
}
count ++;
count %= 50;
}
最后我们将上次工程的while函数改写成如下所示: while (1)
{
delay_ms(10);
KeyResetScan(); //长按P3.2口按键触发软件复位,进入USB下载模式,不需要此功能可删除本行代码
if(DeviceState != DEVSTATE_CONFIGURED)//判断USB设备是否配置完成
continue;
if (bUsbOutReady)
{
if ((UsbOutBuffer == 'K') &&
(UsbOutBuffer == 'E') &&
(UsbOutBuffer == 'Y') &&
(UsbOutBuffer == 'P'))
{
switch (UsbOutBuffer)
{
case VK_DIGIT_0: //初始化菜单
OLED_Init();
Menu_Init();
break;
case VK_UP: //选择上一个菜单
Menu_Last();
break;
case VK_DOWN: //选择下一个菜单
Menu_Next();
break;
case VK_RETURN: //进入菜单
Menu_Enter();
break;
case VK_BACK: //返回上一级菜单
Menu_Back();
break;
}
}
else
{
memcpy(UsbInBuffer, UsbOutBuffer, OutNumber); //原路返回, 用于测试
usb_IN(OutNumber);
}
usb_OUT_done();
}
Func();
}最后的最后,再把本次用到的这些中文字在进行一下取模操作,这样就可以开始我们的测试咯;
3.5功能测试将程序编译下载完成后,将HEX文件下载至开天斧,可以实现如下功能:0.按下虚拟键盘上的“0”按键实现1.按虚拟键盘上的上或者下方向键可以切换当前选中的菜单;2.有子菜单的界面按下Enter按键可以进入菜单;3.没有子菜单的界面按下Enter按键可以进入终端菜单,有用户函数的菜单选项可以执行用户函数;4.按下虚拟键盘上的BkSp可以返回上级菜单。完整的功能演示如下面的动图:
看完了文章,觉得有用的请在文末点个赞哈哈;P由于附件的空间受限,需要上述所需文件的请进群下载哈~群号见第一篇帖子的序言部分
太棒了,这个计算器内容太多了,能学到不少东西,楼主辛苦了:handshake 非常奈斯,多级菜单的实现方法,受益匪浅收藏了, 超级强悍的技术贴,大补猛药 我和你的思路一模一样,就差变量名相同了 :handshake
不过我用的是反显,画面就重绘一次。以前用STC89速度慢的时候也是用“>”代表光标
LiHooo 发表于 2022-11-17 12:38
我和你的思路一模一样,就差变量名相同了
:lol好东西在哪里都不过时,确实反显也很好用,还有用图标也不错:handshake知音难觅,赞一个 :),好文章,学习了,谢谢分享! 收藏+学习! 好 欢迎进入STC大家庭,如需样品支持,可直接联系:
【免费+包邮】 送/申样热线:0513-55012928、0513-55012929、0513-55012966
工作时间:8:30-12:0013:00-17:30(周一 到 周五, 法定节假日除外)
加STC华南区客服刘经理QQ: 3398500488 ;微信:18106296592要求 【免费+包邮】 送, 还免费教你仿真
加STC华南区客服曹经理QQ:1933892258 ;微信:18106296595 要求 【免费+包邮】 送, 还免费教你仿真
加STC华东区客服聂经理QQ:2593903262;微信:18106296598要求 【免费+包邮】 送, 还免费教你仿真
加STC西北区客服孙经理QQ: 1347154513 ;微信:18106296593要求 【免费+包邮】 送, 还免费教你仿真
加STC华北区客服石经理QQ: 1638975601 ;微信:19952583876要求 【免费+包邮】 送, 还免费教你仿真
加STC华中区客服唐经理QQ:2571301708 ;微信:18106296589 要求 【免费+包邮】 送, 还免费教你仿真
加STC东北区客服张经理QQ:3141888640 ;微信:19952583265 要求 【免费+包邮】 送, 还免费教你仿真