微光互联 TX800-U 扫码器无法输出中文到光标的问题

问题背景

某检测场有一批扫码器,购于微光互联,型号 TX800-U,用于在不同办理窗口间扫描纸质材料上的二维码,简化录入过程。扫码器通过 USB 接入 PC 系统 (windows),自动安装驱动,接电即可使用,扫描的信息会直接输出在光标所在位置,扫码器大概长这样:

问题现象

在一次 IT 系统升级后,发现它们扫描不了车辆外观检验单上的车牌二维码了,扫车架号二维码是没问题的,两者的区别就是是否带汉字,车牌第一个字符为地区标识,例如“京”,而车架号全部由数字和大写字母组成。

拿到设备后,第一时间验证了上面的问题,扫码后都有滴的一声,但是车牌号没有任何信息上屏。为了验证这个问题确实和汉字有关,找到一个制造二维码的网站 (草料二维码),造了几个不同的二维码供扫码器扫描,发现带汉字的果然扫不出来,而只要去掉汉字,就能正确上屏。

同样的二维码,通过微信扫一扫是可以得到汉字结果的,另外升级 IT 系统前扫码器也是正常的,所以初步判断这个问题和汉字编码相关,可能是升级系统后修改了默认字符编码集导致扫码器出问题了。

问题的解决

联系了厂家的售后,拉了个微信群,开发人员说这是已知问题,要想解决需要二次开发。

二次开发不就是调用 sdk 接口吗,这个我熟啊。从官网找到对应产品和型号:

下载了 windows 上的 C/C++ 二次开发 sdk:

另外发现一个配置工具,感觉蛮有用,一起搞下来:

话说这公司够实诚,设计图纸都开源了 😅

开发者模式

撸了一遍文档,大概明白了,这个扫码器默认工作在普通模式,这种模式下会将扫到字符直接输出到系统光标位置;如果想要二次开发,需要先将扫码器设置为开发者模式,在这种模式下,扫到的信息不会输出到光标,而是借由 sdk 接口返回给调用者,在这里就可以对数据进行任意加工了。来看看如何配置开发者模式,共有两种方式

  • 通过配置工具 VguangConfig
  • 直接扫描文档中的二维码

其实第一步最终也是生成一个二维码,殊途同归,不过可以选择的设置项更丰富一些,先来看看这种方式吧

VguangConfig

打开后的界面是这样,当扫码器处于普通模式时会自动识别并连接设备:

如果已经处于开发者模式,则无法自动连接,这里直接点“下一页”

工作方式选择“开发”后点“下一页”:

这里有一些高级设置,在当前场景下主要关注扫码设置这页,里面有诸如码制、前后缀、添加回车换行符、扫码间隔时间、扫码后动作、背光灯开关等,一般选择默认即可。配置好后点右侧的“生成配置码”,得到一张二维码配置图:

扫码器扫这个码后再重新加电就可以按新模式工作了。同理可以设置扫码器按普通模式工作:

文档中的配置二维码

上面那个配置工具的优点是灵活,缺点是只支持 windows 平台,如果没有 windows 机器,可以直接使用开发文档中几个预定义的配置二维码:

这个文档位于 C/CPP 开发包解压后的如下路径:“USB接口C-CPP语言SDK20220411\USB接口C&CPP语言SDK20220411\扫码器C&CPP简易开发指南v2.1.pdf”,其它开发包是否有这个文件没有验证过。

对比两组图,生成的二维码和文档中的几乎一样,看起来后者也像通过工具生成的。

Demohidprotocal

进入开发模式后再扫码就只是滴滴叫不上屏了,此时需要使用 sdk 写一个程序来获取扫码器的输出,在 C/CPP 开发包有中一个现成的 demo:USB接口C-CPP语言SDK20220411\USB接口C&CPP语言SDK20220411\Demohidprotocal\Release\Demohidprotocal.exe,这是 release 版本,选择 debug 版本也行,启动后界面如下:

表示连接扫码器成功,分别扫描车架号和车牌号:

vbar_open success!
开始解码:
二维码长度:18
LFV3A23C083027701
二维码长度:10
浜琈D0926

车架号是正常的,而车牌号果然是乱码。

找到 Demohidprotocol 源码 (USB接口C-CPP语言SDK20220411\USB接口C&CPP语言SDK20220411\Demohidprotocal\Demohidprotocal\main.cpp):

#include "channel.h"
#include <stdio.h>
#include <windows.h>
#include <string.h>


struct vbar_channel *dev;

/*背光灯开关控制  state为1时打开补光灯,为0时关闭补光灯*/
void lightswitch(int state)
{

	unsigned char buf[1025] = { 0 };
	if (state == 1)
	{
		buf[0] = 0x55;
		buf[1] = 0xAA;
		buf[2] = 0x24;
		buf[3] = 0x01;
		buf[4] = 0x00;
		buf[5] = 0x01;
		buf[6] = 0xDB;
		vbar_channel_send(dev, buf, 1024);
	}
	else
	{
		buf[0] = 0x55;
		buf[1] = 0xAA;
		buf[2] = 0x24;
		buf[3] = 0x01;
		buf[4] = 0x00;
		buf[5] = 0x00;
		buf[6] = 0xDA;
		vbar_channel_send(dev, buf, 1024);
	}
}
/*扫码开关控制 state为1时打开扫码,为0时关闭扫码*/
void scanswitch(int state)
{

	unsigned char buf[1025] = {0};
	if (state == 1)
	{
		buf[0] = 0x55;
		buf[1] = 0xAA;
		buf[2] = 0x05;
		buf[3] = 0x01;
		buf[4] = 0x00;
		buf[5] = 0x00;
		buf[6] = 0xfb;
		vbar_channel_send(dev, buf, 1024);
	}
	else
	{
		buf[0] = 0x55;
		buf[1] = 0xAA;
		buf[2] = 0x05;
		buf[3] = 0x01;
		buf[4] = 0x00;
		buf[5] = 0x01;
		buf[6] = 0xfa;
		vbar_channel_send(dev, buf, 1024);	
	}
}
int main() {
	dev = vbar_channel_open(1, 1); 
	if (!dev) {
		printf("open dev fail!\n");
		return -1;
	}
	else
	{
		printf("open dev success!\n");
	}
	printf("开始解码:\r\n");

	scanswitch(1);
	//接收扫码
	unsigned char bufresult[1024] = {0};
	unsigned char bufferrecv_1[1024] = {0};
	unsigned char readBuffers[2048] = {0};
	while (1)
	{
		if (vbar_channel_recv(dev, bufresult, 1024, 200) > 0)
		{
			if (bufresult[0] == 0x55 && bufresult[1] == 0xAA && bufresult[2] == 0x30)
			{
				int datalen = bufresult[4] + (bufresult[5] << 8);
			
				if (datalen <= 1017)
				{
					for (int s1 = 0; s1 < datalen; s1++)
					{
						readBuffers[s1] = bufresult[6 + s1];
					}
	
				}
				if (1017 < datalen && datalen <= 2041)
				{
					for (int s1 = 0; s1 < 1018; s1++)
					{
						readBuffers[s1] = bufresult[6 + s1];
					}
					vbar_channel_recv(dev, bufferrecv_1, 1024, 200);
					for (int s2 = 0; s2 < datalen + 7 - 1025; s2++)
					{
						readBuffers[s2 + 1018] = bufferrecv_1[s2];
					}
				}

				printf("二维码长度:%d\n", datalen);
				readBuffers[datalen + 7] = '\0';
				printf("%.*s\n", datalen, readBuffers);
				
			}
		}	
	}
}

谜之编码风格,另外这接口设计的也有点凌乱,程序中出现了好多魔数:1017/1018/2041/200/7,看着头大。所幸读取的数据位于 readBuffers 缓冲中,只要对它做个编码转换就 OK 啦。

编码转换

windows 中文版编码一般是 gb2312,汉字源编码则可能是 utf-8,为了验证这一点,搬出来了 iconv:

$ echo "浜琈D0926" | iconv -f 'utf-8' -t 'cp936'
京MD0926

看来确实如此,注意这里使用 cp936 而不是  gb2312 作为 iconv 的第二个参数。如果没有 iconv,也有许多线上的编码转换工具可用:

确定了字符集转换方向,直接从网上搜罗来一些现成的实现:

std::wstring utf8_to_unicode(std::string const& utf8)
{
    int need = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, NULL, 0); 
    if (need > 0)
    {
        std::wstring unicode; 
        unicode.resize(need); 
        MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &unicode[0], need); 
        return unicode; 
    }

    return std::wstring(); 
}

std::string unicode_to_gb2312(std::wstring const& unicode)
{
    int need = WideCharToMultiByte(936, 0, unicode.c_str(), -1, NULL, 0, NULL, NULL);
    if (need > 0)
    {
        std::string gb2312;
        gb2312.resize(need);
        WideCharToMultiByte(936, 0, unicode.c_str(), -1, &gb2312[0], need, 0, 0);
        return gb2312;
    }

    return std::string();    
}

std::string utf8_to_gb2312(std::string const& utf8)
{
    std::wstring unicode = utf8_to_unicode(utf8); 
    return unicode_to_gb2312(unicode); 
}

windows 上编码转换都是先转到 unicode,再转其它编码,比较好理解。那么 demo 中的输出就可以改为:

std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen)); 
printf("%.*s\n", gb2312.lenght(), gb2312.c_str());

再次运行:

二维码长度:10
京MD0926

恢复正常!

输出到剪贴板

上面的过程虽然能正确解析 utf-8 数据了,但还需要用户复制 console 输出的结果,很不方便,如果能将结果直接输出到剪贴板上岂不是很爽?说干就干:

void copy_to_system_clipboard(std::string const& data)
{
    printf("ready to copy data: %s\n", data.c_str()); 
    BOOL ret = OpenClipboard(NULL);
    if (!ret)
    {
        printf("open clipboard failed\n"); 
        return; 
    }

    do
    {
        ret = EmptyClipboard(); 
        if (!ret)
        {
            printf("empty clipboard failed\n"); 
            break; 
        }

        HGLOBAL hdata = GlobalAlloc(GMEM_MOVEABLE, data.length() + 1); 
        if (hdata == NULL)
        {
            printf("alloc data for clipboard failed"); 
            break;
        }

        char* str = (char *) GlobalLock(hdata); 
        memcpy(str, data.c_str(), data.length()); 
        str[data.length()] = 0; 
        GlobalUnlock(hdata); 

        // HANDLE h = SetClipboardData(CF_UNICODETEXT, hdata); 
        HANDLE h = SetClipboardData(CF_TEXT, hdata);
        if (!h)
        {
            printf("set clipboard data failed"); 
            break; 
        }

        printf("copy to clipboard ok\n"); 
    } while (0);
    CloseClipboard(); 
}

基本上是抄了网上一个例子实现的,只是增加了一些错误提示。调用点稍微改造就大功告成:

printf("%.*s\n", datalen, readBuffers);
std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen)); 
copy_to_system_clipboard(gb2312);

再次运行:

二维码长度:10
浜琈D0926
ready to copy data: 京MD0926
copy to clipboard ok

此时在任一文本框中按 Ctrl+V,均能得到号牌数据。

这里请注意 copy_to_system_clipboard 中的 SetClipboardData 调用,使用 CF_TEXT 而不是 CF_UNICODETEXT,否则会得到下面的乱码输出:

ꦾ䑍㤰㘲

另外测试中发现可以同时启动多个 demo,相互之间不冲突,均能从接口拿到扫描后的数据,神奇。

输出到当前光标

上面的解决方案已经很好了,但是如果能像之前一样输出到光标就更棒了,用户可以无疑切换。作为资深 MFCer,立刻想到了一种解决方案:查找当前桌面前台 (Foreground) 窗口,找到它的活动子窗口并投递 WM_SETTEXT 消息。下面是参考网上一个例子的实现:

void set_text_to_active_windows(std::string const& data)
{
    int ret = 0; 
    std::wstring unicode; 
    HWND wnd = GetForegroundWindow();
    //HWND wnd = GetActiveWindow(); 
    //HWND wnd = GetDesktopWindow(); 
    if (wnd == NULL)
    {
        printf("no active windows\n"); 
        return; 
    }

    printf("get active window\n"); 
    DWORD SelfThreadId = GetCurrentThreadId();
    DWORD ForeThreadId = GetWindowThreadProcessId(wnd, NULL);
    if (!AttachThreadInput(ForeThreadId, SelfThreadId, true))
    {
        printf("attach thread input failed\n"); 
        return; 
    }

    printf("attach thread input\n"); 
    //wnd = GetFocus();
    wnd = GetActiveWindow(); 
    if (wnd == NULL)
    {
        printf("no focus windows\n"); 
        return; 
    }

    printf("get focus window\n"); 
    AttachThreadInput(ForeThreadId, SelfThreadId, false);
    unicode = gb2312_to_unicode(data); 
    ret = SendMessage(wnd, WM_SETTEXT, 0, (LPARAM)unicode.c_str());
    printf("send text to active window return %d: %s\n", ret, data.c_str()); 
}

调用点仅需稍加改造就可以了:

printf("%.*s\n", datalen, readBuffers);
std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen)); 
copy_to_system_clipboard(gb2312);
set_text_to_active_windows(gb2312);

编译运行,先启动一个 notepad 应用,将光标置于其中,便于稍后看输出结果,然而扫码后没有任何输出。将上面的 GetForegroundWindow 替换为 GetActiveWindow 或 GetDesktopWindows 都没有效果,更神奇的是加的许多 printf 调试日志也没有输出,这真是见了鬼了:

open dev success!
开始解码:
二维码长度:10
浜琈D0926
ready to copy data: 京MD0926
copy to clipboard ok
send text to active window return 0: 京MD0926
二维码长度:18
LFV3A23C083027701
ready to copy data: LFV3A23C083027701
copy to clipboard ok
send text to active window return 0: LFV3A23C083027701

只输出最终的一个调用结果。一开始怀疑是 console 程序和 win32 界面程序的不同,决定新建一个新的 win32 应用试试,由于 Win32 应用的主线程要做消息循环,这里启动一个单独的线程跑扫码的逻辑:

int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
                     _In_opt_ HINSTANCE hPrevInstance,
                     _In_ LPWSTR    lpCmdLine,
                     _In_ int       nCmdShow)
{
    UNREFERENCED_PARAMETER(hPrevInstance);
    UNREFERENCED_PARAMETER(lpCmdLine);

    // TODO: Place code here.

    // Initialize global strings
    LoadStringW(hInstance, IDS_APP_TITLE, szTitle, MAX_LOADSTRING);
    LoadStringW(hInstance, IDC_DEMOHIDPROTOCAL, szWindowClass, MAX_LOADSTRING);
    MyRegisterClass(hInstance);

    // Perform application initialization:
    if (!InitInstance (hInstance, nCmdShow))
    {
        return FALSE;
    }

    hThread = CreateThread(NULL, 0, qrscanner_loop, NULL, 0, NULL); 
    HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_DEMOHIDPROTOCAL));

    MSG msg;

    // Main message loop:
    while (GetMessage(&msg, nullptr, 0, 0))
    {
        if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
    }

    return (int) msg.wParam;
}

qrscanner_loop 就是之前 console main 那一堆东西了,为了展示信息,在默认的视图中间填充一个 edit 控件:

BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
{
   hInst = hInstance; // Store instance handle in our global variable

   HWND hWnd = CreateWindowW(szWindowClass, szTitle, WS_OVERLAPPEDWINDOW,
      CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, nullptr, nullptr, hInstance, nullptr);

   if (!hWnd)
   {
      return FALSE;
   }

   RECT rect = { 0 }; 
   GetClientRect(hWnd, &rect); 
   hEdit = CreateWindowW(TEXT("Edit"), TEXT(""), 
       WS_CHILD | WS_VISIBLE | ES_LEFT | ES_MULTILINE | ES_WANTRETURN | WS_VSCROLL | ES_AUTOVSCROLL,
       rect.left, rect.top, rect.right-rect.left, rect.bottom-rect.top, hWnd, (HMENU)10002, hInstance, NULL);
   ShowWindow(hWnd, nCmdShow);
   UpdateWindow(hWnd);

   return TRUE;
}

至于编辑框随视图大小变化而变化这种基本功,就不赘述了,后面会放出完整源码。注意这里的 hEdit,它存储着编辑框的句柄,后面会用到。

console 改 win32 最大的变化是 printf 日志输出没有了,为了解决这个问题,改写 printf 为 my_printf,在里面做些文章:

extern HWND hEdit;
void my_printf(char const* format, ...)
{
    char line[4096] = { 0 };
    va_list vp;
    va_start(vp, format);
    vsprintf(line, format, vp);
    va_end(vp);

    // replace '\n' to '\r\n'
    if (strlen(line) > 0)
        line[strlen(line) - 1] = '\r'; 
    strcat(line, "\n"); 

    //std::wstring data = gb2312_to_unicode(line);
    // SendMessage(hEdit, WM_SETTEXT, 0, (WPARAM)data.c_str());
    SendMessage(hEdit, EM_SETSEL, -2, -1); 
    SendMessageA(hEdit, EM_REPLACESEL, true, (long)line); 
    SendMessage(hEdit, WM_VSCROLL, SB_BOTTOM, 0); 
    OutputDebugStringA(line); 
}

基本就是将日志发往刚才的 hEdit,注意这里不使用 WM_SETTEXT 以免冲掉历史消息,最后上张效果图:

再看下新版 set_text_to_active_windows 的实现:

void set_text_to_active_windows(std::string const& data)
{
    int ret = 0; 
    wchar_t const* str; 
    HWND wnd = GetForegroundWindow();
    //HWND wnd = GetActiveWindow(); 
    //HWND wnd = GetDesktopWindow(); 
    if (wnd == NULL)
    {
        my_printf("no active windows\n"); 
        return; 
    }

    my_printf("get active window\n"); 
    DWORD SelfThreadId = GetCurrentThreadId();
    DWORD ForeThreadId = GetWindowThreadProcessId(wnd, NULL);
    if (!AttachThreadInput(ForeThreadId, SelfThreadId, true))
    {
        my_printf("attach thread input failed\n"); 
        return; 
    }

    my_printf("attach thread input\n"); 
    //wnd = GetFocus();
    wnd = GetActiveWindow(); 
    if (wnd == NULL)
    {
        my_printf("no focus windows\n"); 
        return; 
    }

    my_printf("get focus window\n"); 
    AttachThreadInput(ForeThreadId, SelfThreadId, false);
    //std::wstring unicode = gb2312_to_unicode(data); 
    std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter; 
    std::wstring tst = converter.from_bytes(data); 
    str = tst.data(); 
    //ret = SendMessageA(wnd, WM_SETTEXT, 0, (LPARAM)data.c_str());
    for (int i=0; str[i] != '\n'; ++i)
    {
        ret = SendMessage(hEdit, WM_CHAR, str[i], 0);
    }

    my_printf("send text to active window return %d: %s\n", ret, data.c_str()); 
}

与之前版本相比,除了 printf 变为 my_printf,最大的变化是在结尾部分:使用 WM_CHAR 消息代替 WM_SETTEXT。这样做是为了更好的模拟光标行为,毕竟不能假设用户光标一定位于 windows edit 控件上,有可能位于绘制界面框架 (Qt) 或描述界面框架 (Electron) 生成的 App 的控件上,这个消息可以实现字符被一个个输入编辑框的效果,兼容上述所有控件。

满怀期待的启动应用后,出现和 console 程序一样的行为——光标下没有任何输出,且不打印任何调试日志,遇到中文字符还会崩溃:

看崩溃点没什么头绪,表现还不如 console 呢,这下把我整不会了,最终这个方案宣告失败。不过留着还是有意义的,万一有人基于它实现了光标输出呢…

结语

本文尝试解决扫码器在遇到中文时不输出字符的问题,总体上解决了这个问题,优雅的解决方案因技术问题没有实现,不优雅的解决方案针对检测场的需求来说也够用了。

最早想的技术方案其实是不想动 demo 程序的,当时想通过在外面包一层 shell 脚本来解决,熟悉的读者知道我喜欢用这种方式解决一些问题,当时主要有两个原因导致想这样干:

  • 家里的 windows 笔记本没装 VS,安装 VS2015 一来比较慢,二来拖累机器运行速度,不想装
  • demo 程序已经比较完整,只缺一个编码转换的工作,而用脚本调用 iconv 一行就能搞定,何必费力写 c++ 呢?

后面亲自试过后,发现有两个问题 shell 脚本无法绕开:

  • demo 的输出在经过 msys2 处理后,无法正确断行,导致无法从输出信息中提取扫码器读取的数据,对于这个问题
    • 开始怀疑是管道重定向后 stdout 不再是行缓冲的,而在 shell 层面无法改变一个程序的 stdout 缓冲类型
    • 后来修改 demo 源码,增加 setvbuf 调用 (参考《[apue] 标准 I/O 库那些事儿 》中缓冲一节),重新编译但不起作用
    • 最终定性是 msys2 与 demo 之间的兼容性问题,不好搞,放弃
  • 想要将数据复制到系统剪贴板,可以直接在 msys2 中使用 windows 的 clip 命令接收要放置的数据 (echo "${data}" | clip),但是如果想将数据输出到光标,对不起办不到。这个必需用 c++ 进行系统开发 (后来也没走通,不过这是后话)

最终是将公司的 windows 本带回来专门搞这个事情,那个开发环境配置的比较全面,不用浪费时间再配了。说到这里,突然想到为何没有人搞在线的 VS 开发环境?linux 上的 gcc 这种环境一搜一大把,提交个文件或直接在 web 界面里写 c++ 代码,就能编译出可执行文件,而免费的 VS 线上开发环境却几乎没有!如果有人搞个 VS 的在线编译环境,肯定能火,哪怕编译一次收个十元二十元的,我估计也有人用。

上面说了一些解决过程中的探索,下面谈谈这个扫码器的问题,如果它能将编码转换功能集成在硬件里,通过配置来决定如何进行编码转换,那么这个场景就不需要二次开发 sdk 了!只要运行下 VguangConfig 并做一些勾选工作就可以了,如果再将常用的几种编码转换做成二维码配置放在文档中,直接扫对应的码就搞定了!后续给厂家反馈时,厂家表示可以考虑,其实就是增加一个 iconv.dll 的事儿,不难!

最后说一下系统升级导致扫码器不能用的问题,这就是典型的没做系统集成测试案例啊!新系统没有兼容老系统的一些隐性规则,导致下游出问题,其实完全可以让升级系统的软件厂商改进一下它这个二维码的生成方式,是用 utf8 还是 gb2312,搞成可配置的,操作人员通过配置来保持以前的编码方式不变,这个问题也能得到解决。

下载

扫码器 sdk 官网就可以下载,两个应用的源码及可执行文件链接如下:

Demohidprotocol console 版

Demohidprotocol win32 版

console 版可以直接用,win32 版还是个半成品,感兴趣的读者可以尝试探索一下。

console 版的可执行文件为 debug 版本,release 版本不知为何编译报错:

1>------ Build started: Project: Demohidprotocal, Configuration: Release Win32 ------
1>main.obj : error LNK2001: unresolved external symbol __imp__SetClipboardData@8
1>main.obj : error LNK2001: unresolved external symbol __imp__EmptyClipboard@0
1>main.obj : error LNK2001: unresolved external symbol __imp__CloseClipboard@0
1>main.obj : error LNK2001: unresolved external symbol __imp__OpenClipboard@4
1>D:\BaiduNetdiskDownload\USB接口C&CPP语言SDK20220411\Demohidprotocal\Release\Demohidprotocal.exe : fatal error LNK1120: 4 unresolved externals
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========

看起来和操作系统剪贴板相关,因时间关系没有进一步去研究。

参考

[1]. 草料二维码

[2]. 微光互联

[3]. Windows下的字符集转换(ASCII、UICODE、UTF8、GB2312和BIG5互转)

[4]. 编码转换

[5]. 剪贴板操作

[6]. Windows/Mac/Linux/ssh将shell内容输出到剪贴板

 

posted @ 2022-10-18 12:12  goodcitizen  阅读(774)  评论(2编辑  收藏  举报