【问题标题】:How to implement a thread in a wxPython GUI application如何在 wxPython GUI 应用程序中实现线程
【发布时间】:2011-10-05 19:09:48
【问题描述】:

我在正确实现线程以防止我的应用程序锁定和出现奇怪行为时遇到问题。该应用程序旨在登录到基于 ubuntu 的服务器或 ubuntu 嵌入式服务器,并搜索可能明确的日志文件。嵌入式服务器可以工作,但在搜索发生时应用程序会一直锁定。站点服务器不会处理。我还没有编写本地文件搜索代码。一旦我弄清楚如何实现线程,我想添加一个进度条。我认为这将是直截了当的,因为我已经学习和使用 Python 几个月了,但是使用 GUI 有其挑战。我还是个新手,对所有批评持开放态度;它只会帮助我成为一个更好的程序员。任何帮助是极大的赞赏。下面是代码:

#!c:\python27

import wx
import os
import re
import paramiko
import string
import fileinput
import os.path
import dircache
import sys
import time
import datetime, time
import wx

from wxGui import *



class MyApp(wx.App):
    def OnInit(self):
        frame = MyFrame("SecureTool v2.0.0", (50, 60), (458, 332))
        frame.Show()
        self.SetTopWindow(frame)
        return True



class MyFrame(wx.Frame):
    def __init__(self, title, pos, size):
        wx.Frame.__init__(self, None, -1, title, pos, size)

        toolbar = self.CreateToolBar()
        toolbar.Realize()
        menuFile = wx.Menu()
        menuFile.Append(1, "&About...")
        menuFile.AppendSeparator()
        menuFile.Append(2, "E&xit")
        menuBar = wx.MenuBar()
        menuBar.Append(menuFile, "&File")
        menu2 = wx.Menu()
        menu2.Append(wx.NewId(), "&Copy", "Copy in status bar")
        menu2.AppendSeparator()
        menu2.Append(wx.NewId(), "C&ut", "")
        menu2.AppendSeparator()
        menu2.Append(wx.NewId(), "Paste", "")
        menu2.AppendSeparator()
        menu2.Append(wx.NewId(), "&Options...", "Display Options")
        menuBar.Append(menu2, "&Edit")

        self.SetMenuBar(menuBar)
        self.CreateStatusBar()
        self.SetStatusText("Welcome to SecureTool!")
        self.Bind(wx.EVT_MENU, self.OnAbout, id=1)
        self.Bind(wx.EVT_MENU, self.OnQuit, id=2)
        panel = wx.Panel(self)
        panel.SetBackgroundColour('LIGHT GREY')
        #Close button
        button = wx.Button(panel, label="EXIT", pos=(229, 160), size=(229, 80))
        self.Bind(wx.EVT_BUTTON, self.OnQuit, button)
        self.Bind(wx.EVT_CLOSE, self.OnCloseWindow)
        #Embed Server button
        button2 = wx.Button(panel, label="Embed Server", pos=(0, 160), size=(229, 80))
        self.Bind(wx.EVT_BUTTON, self.OnIP, button2)
        #Site Server
        button3 = wx.Button(panel, label="SITESERVER", pos=(0, 80), size=(229, 80))
        self.Bind(wx.EVT_BUTTON, self.OnUsrPswd, button3)
        #Local Search
        button4 = wx.Button(panel, label="LOCAL SEARCH", pos=(229, 80), size=(229, 80))
        self.Bind(wx.EVT_BUTTON, self.OnOpen, button4)

        EVT_RESULT(self, self.OnResult)
        self.worker = None        

    def OnIP(self, event):
        ip_address = 0
        result = ''
        dlg = wx.TextEntryDialog(None, "Enter the IP Address.",
        'Embed Server Connect', 'xxx.xxx.xxx.xxx')
        if dlg.ShowModal() == wx.ID_OK:
            ip_address = dlg.GetValue()
        if ip_address:    
            cmsg = wx.MessageDialog(None, 'Do you want to connect to: ' + ip_address,
                                    'Connect', wx.YES_NO | wx.ICON_QUESTION)
            result = cmsg.ShowModal()

        if result == wx.ID_YES:
            self.DispConnect(ip_address)

            cmsg.Destroy()
        dlg.Destroy()
        return True

    def OnUsrPswd(self, event):
        passwrd = 0
        result = ''
        result = wx.TextEntryDialog(None, 'Enter Weekly Password', 'Site Server login','')
        if result.ShowModal() == wx.ID_OK:
            passwrd = result.GetValue()
        if passwrd:
            psmsg = wx.MessageDialog(None, 'Do you want to connect to the Siteserver?', 'Connect',
                                 wx.YES_NO | wx.ICON_QUESTION)
            result = psmsg.ShowModal()
        if result == wx.ID_YES:
            self.SiteserverConnect(passwrd)

            psmsg.Destroy()
        result.Destroy()
        return True

    def ErrMsg(self):
        ermsg = wx.MessageDialog(None, 'Invalid Entry!', 'ConnectionDialog', wx.ICON_ERROR)
        ermsg.ShowModal()
        ermsg.Destroy()

    def GoodConnect(self):
        gdcnt = wx.MessageDialog(None, 'You are connected!', 'ConnectionStatus', wx.ICON_INFORMATION)
        gdcnt.ShowModal()
        #if gdcnt.ShowModal() == wx.ID_OK:
        gdcnt.Destroy()

    def OnFinish(self):
        finish = wx.MessageDialog(None, 'Job is finished!', 'WorkStatus', wx.ICON_INFORMATION)
        finish.ShowModal()
        finish.Destroy()


    def DispConnect(self, address):
        pattern = r"\b(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"
        port = 22
        user = 'root'
        password ='******'
        if re.match(pattern, address):
            ssh = paramiko.SSHClient()
            ssh.load_system_host_keys()
            ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            ssh.connect(address,port,user,password)
            Ssh = ssh
            self.GoodConnect()
            self.OnSearch(Ssh)
        else:
            self.ErrMsg()

    def SiteserverConnect(self, password):
        port = 22
        user = 'root2'
        address = '10.5.48.2'
        if password:
            ssh = paramiko.SSHClient()
            ssh.load_system_host_keys()
            ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            ssh.connect(address,port,user,password)
            Ssh = ssh
            self.GoodConnect()
            self.OnSiteSearch(Ssh)
        else:
            self.ErrMsg()

    def startWorker(self,a, b, c):
        self.button2.Disable()
        self.thread = Thread(target=self.LongRunningSearch)
        self.thread.start()

    def OnSearch(self, sssh):
        self.startWorker(self.OnFinish, self.LongRunningSearch, wargs=[sssh])
        self.OnFinish()

    def LongRunningSearch(sssh):
        ssh = sssh
        apath = '/'
        apattern = '"*.txt" -o -name "*.log"' 
        rawcommand = 'find {path} -name "*.txt" -o -name "*.log"' 
        command1 = rawcommand.format(path=apath, pattern=apattern)
        stdin, stdout, stderr = ssh.exec_command(command1)
        filelist = stdout.read().splitlines()
        ftp = ssh.open_sftp()
        for afile in filelist:
            (head, filename) = os.path.split(afile)

        paths = '/dispenser_result.log'
        temp = ftp.file(paths, 'w')
        from time import strftime
        temp.write("{0:^75}".format("Company -Security Report" ) + strftime("    %Y-%m-%d %H:%M:%S") + "\n\n")   
        ustring = wx.TextEntryDialog(None, 'Enter a search string below:', 'Search', 'String Name')
        if ustring.ShowModal() == wx.ID_OK:
            userstring = ustring.GetValue()

        if userstring:
        userStrHEX = userstring.encode('hex')
        userStrASCII = ''.join(str(ord(char)) for char in userstring)
        regex = re.compile(r"(%s|%s|%s)" % ( re.escape( userstring ), re.escape( userStrHEX ), re.escape( userStrASCII )))      
    else:
        sys.exit('You Must Enter A String!!!')

    count = 0
    for afile in filelist:
        (head, filename) = os.path.split(afile)
        if afile.endswith(".log") or afile.endswith(".txt"):
            f=ftp.open(afile, 'r')
            for i, line in enumerate(f.readlines()):
                result = regex.search(line)
                if result:
                    count += 1
                    ln = str(i)
                    pathname = os.path.join(afile)
                    template = "\n\nLine: {0}\nFile: {1}\nString Type: {2}\n\n"
                    output = template.format(ln, pathname, result.group())
                    ftp.get(afile, 'c:\\Extracted\\' + filename)
                    temp.write(output)
                    break
            else:
                #print "No Match in: " + os.path.join(afile)
                temp.write("\nNo Match in: " + os.path.join(afile))
            f.close()

    for fnum in filelist:
        #print "\nFiles Searched: ", len(filelist)
        #print "Files Matched: ", count
        num = len(filelist)

        temp.write("\n\nFiles Searched:" + '%s\n' % (num))
        temp.write("Files Matched:"+ '%s\n' % (count))
        temp.write("Search String:"+ '%s\n' % (userstring))
        break
        temp.close()
    defaultFolder = "DispenserLogResults"
    if not defaultFolder.endswith(':') and not os.path.exists('c:\\Extracted\\DispenserLogResults'):
        os.mkdir('c:\\Extracted\\DispenserLogResults')
    else:
         pass
    ftp.get(paths, 'c:\\Extracted\\DispenserLogResults\\dispenser_result.log')

    ftp.remove(paths)

    re.purge()
    ftp.close()
    ssh.close()

    def OnSiteSearch(self, sssh):
        ssh = sssh
        apath = '/var/log/apache2 /var/opt/smartmerch/log/'
        apattern = '"*.log"' 
        rawcommand = 'find {path} -type f -name "*.log"' 
        command1 = rawcommand.format(path=apath, pattern=apattern)
        stdin, stdout, stderr = ssh.exec_command(command1)
        filelist = stdout.read().splitlines()
        ftp = ssh.open_sftp()
        for afile in filelist:
            (head, filename) = os.path.split(afile)

        paths = '/var/tmp/siteserver_result.log'
        temp = ftp.file(paths, 'w')
        from time import strftime
        temp.write("{0:^75}".format("Gilbarco - SQA Security Report" ) + strftime("    %Y-%m-%d %H:%M:%S") + "\n\n")   
        temp.write("\n{0:^75}".format("SiteServer Logs" ))
        ustring = wx.TextEntryDialog(None, 'Enter a search string below:', 'Search', 'String Name')
        if ustring.ShowModal() == wx.ID_OK:
            userstring = ustring.GetValue()

        if userstring:
            userStrHEX = userstring.encode('hex')
            userStrASCII = ''.join(str(ord(char)) for char in userstring)
            regex = re.compile(r"(%s|%s|%s)" % ( re.escape( userstring ), re.escape( userStrHEX ), re.escape( userStrASCII )))      
        else:
            sys.exit('You Must Enter A String!!!')

        count = 0
        for afile in filelist:
            (head, filename) = os.path.split(afile)
            if afile.endswith(".log") or afile.endswith(".txt"):
                f=ftp.open(afile, 'r')
                for i, line in enumerate(f.readlines()):
                    result = regex.search(line)
                    if result:
                        count += 1
                        ln = str(i)
                        pathname = os.path.join(afile)
                        template = "\n\nLine: {0}\nFile: {1}\nString Type: {2}\n\n"
                        output = template.format(ln, pathname, result.group())
                        ftp.get(afile, 'c:\\Extracted\\' + filename)
                        temp.write(output)
                        break
                else:
                    temp.write("\nNo Match in: " + os.path.join(afile))
                f.close()

        for fnum in filelist:
            num = len(filelist)

            temp.write("\n\nFiles Searched:" + '%s\n' % (num))
            temp.write("Files Matched:"+ '%s\n' % (count))
            temp.write("Search String:"+ '%s\n' % (userstring))
            break
            temp.close()
        defaultFolder = "SiteServerLogResults"
        if not defaultFolder.endswith(':') and not os.path.exists('c:\\Extracted\\SiteServerLogResults'):
            os.mkdir('c:\\Extracted\\SiteServerLogResults')
        else:
            pass
        ftp.get(paths, 'c:\\Extracted\\SiteServerLogResults\\siteserver_result.log')

        ftp.remove(paths)

        re.purge()
        ftp.close()
        ssh.close()
        self.OnFinish()

    def OnOpen(self,e):
        self.dirname = ''
        dlg = wx.FileDialog(self, "Choose a file", self.dirname, "", "*.*", wx.OPEN)
        if dlg.ShowModal() == wx.ID_OK:
            self.filename = dlg.GetFilename()
            self.dirname = dlg.GetDirectory()
            f = open(os.path.join(self.dirname, self.filename), 'r')
            self.control.SetValue(f.read())
            f.close()
        dlg.Destroy()   

    def OnQuit(self, event):
        self.Close(True)

    def OnAbout(self, event):
        wx.MessageBox("This is sQAST v2.0.0",
            "About secureTool", wx.OK | wx.ICON_INFORMATION, self)



    def OnCloseWindow(self, event):
        self.Destroy()

if __name__ == '__main__':
    app = MyApp(False)
    app.MainLoop()

运行后的Traceback Error:

Traceback (most recent call last):
File "C:\SQA_log\wxGui.py", line 87, in OnIP
self.DispConnect(ip_address)
File "C:\SQA_log\wxGui.py", line 143, in DispConnect
self.OnSearch(Ssh)
File "C:\SQA_log\wxGui.py", line 169, in OnSearch
self.startWorker(self.OnFinish, self.LongRunningSearch, wargs=[sssh])

【问题讨论】:

  • 参考:赏金。我的意思是说 OnSearch 功能。
  • 我在回答中添加了一些示例和更多解释。
  • 我尝试了其他一些组合,这是现在一直发生的错误: Traceback (last recent call last): File "C:\SQA_log\Gui_Thread.py", line 174, in OnIP self. DispConnect(ip_address) 文件“C:\SQA_log\Gui_Thread.py”,第 226 行,在 DispConnect self.OnSearch(Ssh) 文件“C:\SQA_log\Gui_Thread.py”,第 264 行,在 OnSearch 中 t = WorkerThread() TypeError : __init__() 正好接受 2 个参数(给定 1 个)
  • 当然。如果您将notify_window 作为必需参数,则t= WorkerThread() 是错误的。仍然不确定为什么要使用如此复杂的方法?为什么不使用wx.lib.delayedresult
  • 我在两个示例中都添加了中止线程的功能。我还注意到,使用WorkerThread 作为Thread 子类会使您的事情变得复杂,因为您无法在“线程”函数中读取MyFrame 的类属性。

标签: multithreading wxpython


【解决方案1】:

在你的特殊情况下,我会做类似的事情:

1) 封装长任务并将其与 GUI 反应分离,简化您的方法:

def OnSearch(self, sssh):
    self.LongRunningSearch(sssh) # Move all the blocking code here,
                                 # just NOT the GUI reaction !
                                 # Meaning self.OnFinish()...
    self.OnFinish()

2) 验证它是否仍然可以正常运行。然后修改添加线程的方法:

def OnSearch(self, sssh):
    startWorker(self.OnFinish, self.LongRunningSearch, wargs=[sssh])

self.OnSearch 将立即结束,self.OnFinish 将在运行 self.LongRunningSearch 的线程完成后调用。它可能仍需要一些调整,因为我无法在我的计算机上运行您的代码。

【讨论】:

  • 我想澄清一下我没有正确地关注您:我需要在上面的代码中添加一个 LongRunningSearch 方法(类似于您代码中的 LongTask 方法:示例 2)? OnSearch 方法中除 ustring = wx.TextEntry...(GUI) 之外的所有代码都应移至 LongRunningSearch?
  • 您的代码结构有点混乱。尝试将除self.OnFinish() 之外的所有内容移动到 LongRunningSearch。您仍在使用ShowModal(),所以此时您基本上冻结了您的 GUI。在理想情况下,您应该将您的 GUI 与主要功能代码分开。
  • 我按照第 1 步操作,它仍然像以前一样运行(带锁定)。所以,我去了第2步(修改添加线程的方法)。 startWorker 让我有点困惑,因为我将我正在做的事情与下面的示例 2 相关联,我在 OnStartButton 方法中看到你有 self.thread.start()。
  • 您缺少导入 from wx.lib.delayedresult import startWorker 并且您添加了不应存在的 self
  • 我仍在尝试将失败的结局捆绑在一起,但考虑到您为我指明了正确的方向......我很感激。
【解决方案2】:

我在您的应用程序中根本看不到任何线程。每当您调用需要一段时间的东西时,该东西需要在单独的线程中运行,否则它将阻塞 GUI 的主循环。

您应该阅读以下关于 wxPython 中的线程的 wiki 条目:http://wiki.wxpython.org/LongRunningTasks

我已经使用其中的信息成功地创建了线程化的 wxPython 应用程序。这里还有一个简单的线程教程:http://www.blog.pythonlibrary.org/2010/05/22/wxpython-and-threads/

希望对您有所帮助。如果你卡住了,你应该发帖到官方 wxPython 邮件组:https://groups.google.com/forum/#!forum/wxpython-users 那些家伙会让你直截了当。

【讨论】:

  • 我一直在看一些关于线程的教程......也许你可以给我一个正确的方向。我应该在线程类中调用 OnSearch 函数以作为单独的线程运行吗?教程的问题是你不能问问题,即使是最平凡、看似微不足道的问题....
  • 是的,看起来像一个。 SiteSearch 也可能是其中之一。当您将它们关闭时,您可以分辨出它们是哪些。如果您的应用程序在您运行这些方法时冻结,那么这些方法需要在一个线程中。
【解决方案3】:

您还可以查看 wx 中实现的线程的便利模块,wx.lib.delayedresult。它非常易于使用,并且在您发现需要时添加。我不确定为什么它经常被忽略。我写了一个例子,前段时间使用它here

它基本上需要你创建两个函数/方法。首先,它将在另一个线程中运行,其次,它将在另一个线程完成后运行。然后你只需拨打startWorker(LongTaskDone, LongTask)

示例 1: 使用 wx.lib.delayedresultwx.CallAfter 用于使用仪表小部件显示 GUI 线程中的进度。官方Documentation

from time import sleep
import wx
from wx.lib.delayedresult import startWorker

class MainWindow(wx.Frame):
    def __init__(self, *args, **kwargs):
        wx.Frame.__init__(self, *args, **kwargs)

        self.panel = wx.Panel(self)
        self.startButton = wx.Button(self.panel, label="Long Task")
        self.abortButton = wx.Button(self.panel, label="Abort")
        self.abortButton.Disable()
        self.gauge = wx.Gauge(self.panel, size=(-1, 20))
        self.shouldAbort = False

        self.startButton.Bind(wx.EVT_BUTTON, self.OnStartButton)
        self.abortButton.Bind(wx.EVT_BUTTON, self.OnAbortButton)

        self.windowSizer = wx.BoxSizer()
        self.windowSizer.Add(self.panel, 1, wx.ALL | wx.EXPAND)

        self.sizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer.Add(self.startButton)
        self.sizer.Add(self.abortButton)
        self.sizer.Add((10, 10))
        self.sizer.Add(self.gauge)

        self.border = wx.BoxSizer()
        self.border.Add(self.sizer, 1, wx.ALL | wx.EXPAND, 5)

        self.panel.SetSizerAndFit(self.border)  
        self.SetSizerAndFit(self.windowSizer)   
        self.Show()

    def OnStartButton(self, e):
        self.startButton.Disable()
        self.abortButton.Enable()
        startWorker(self.LongTaskDone, self.LongTask)

    def OnAbortButton(self, e):
        self.shouldAbort = True

    def LongTask(self):
        for a in range(101):
            sleep(0.05)
            wx.CallAfter(self.gauge.SetValue, a)
            if self.shouldAbort:
                break
        return self.shouldAbort

    def LongTaskDone(self, result):
        r = result.get()
        if r:
            print("Aborted!")
        else:
            print("Ended!")
        self.startButton.Enable()
        self.abortButton.Disable()
        self.shouldAbort = False
        self.gauge.SetValue(0)

app = wx.App(False)
win = MainWindow(None)
app.MainLoop()

示例 2: 使用标准 threading 模块。在某些情况下,这可能更“丑陋”。我建议使用wx.lib.delayedresult。官方Documentation

from time import sleep
from threading import Thread
import wx

class MainWindow(wx.Frame):
    def __init__(self, *args, **kwargs):
        wx.Frame.__init__(self, *args, **kwargs)

        self.panel = wx.Panel(self)
        self.startButton = wx.Button(self.panel, label="Long Task")
        self.abortButton = wx.Button(self.panel, label="Abort")
        self.abortButton.Disable()
        self.gauge = wx.Gauge(self.panel, size=(-1, 20))
        self.shouldAbort = False
        self.thread = None

        self.startButton.Bind(wx.EVT_BUTTON, self.OnStartButton)
        self.abortButton.Bind(wx.EVT_BUTTON, self.OnAbortButton)

        self.windowSizer = wx.BoxSizer()
        self.windowSizer.Add(self.panel, 1, wx.ALL | wx.EXPAND)

        self.sizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer.Add(self.startButton)
        self.sizer.Add(self.abortButton)
        self.sizer.Add((10, 10))
        self.sizer.Add(self.gauge)

        self.border = wx.BoxSizer()
        self.border.Add(self.sizer, 1, wx.ALL | wx.EXPAND, 5)

        self.panel.SetSizerAndFit(self.border)  
        self.SetSizerAndFit(self.windowSizer)   
        self.Show()

    def OnStartButton(self, e):
        self.startButton.Disable()
        self.abortButton.Enable()
        self.thread = Thread(target=self.LongTask)
        self.thread.start()

    def OnAbortButton(self, e):
        self.shouldAbort = True

    def LongTask(self):
        for a in range(101):
            sleep(0.05)
            wx.CallAfter(self.gauge.SetValue, a)
            if self.shouldAbort:
                break
        wx.CallAfter(self.LongTaskDone, self.shouldAbort)

    def LongTaskDone(self, r):
        if r:
            print("Aborted!")
        else:
            print("Ended!")
        self.startButton.Enable()
        self.abortButton.Disable()
        self.shouldAbort = False
        self.gauge.SetValue(0)

app = wx.App(False)
win = MainWindow(None)
app.MainLoop()

注意:threading.Lock 可能需要线程安全地传递比简单布尔标志更复杂的变量和数据。

编辑:添加示例。

编辑:添加了中止线程的功能。

编辑:简化了示例。根据tom10的想法去掉了timer,也删除了threading.Lock,因为这里不需要。

【讨论】:

  • 我一直试图在我的代码中实现这个概念,但无济于事。当实现一个简单的计时器作为 LongTask 时,该代码可以完美运行。几乎我见过的每个线程示例都使用计时器作为长任务。当实现某事的代码时,它似乎并不那么简单。
  • @user706808 - 我通常在我的应用程序中将它用于从 Internet 进行长时间阅读、长时间计算或类似任务。什么对你不起作用?究竟什么不是“看起来那么直截了当”?你有错误吗?它还会冻结你的 GUI 吗?
  • @user706808 - 如果您想在代码方面获得真正的帮助,您必须创建尽可能小的 runnable 代码示例,我们可以在我们的计算机上尝试重新创建你的问题。这个 example 使用另一个线程来计算分形。
  • 哇,我发现在尝试使用线程之前我已经有了代码。编辑...
【解决方案4】:

在 wxPython 中,所有对 GUI 对象的方法和事件处理程序的调用都需要在主线程中进行。因此,在 wx 中使用线程时需要小心,但这通常很容易进行必要的修改。

例如,在您的代码中,您可以从主线程调用wx.TextEntryDialog,然后将此信息传递给搜索线程。

如果您需要从线程请求 GUI 的操作,最简单的方法是使用 wx.CallAfter,尽管还有其他方法也可以。

【讨论】:

  • 我不知道我可以使用 wx.CallAfter 来更新 GUI,即使是从正在运行的线程。我一直认为它是 线程完成之后调用的。感谢您的洞察力!
  • 我的猜测是,“之后”是指在 GUI 中的当前事件处理完成之后。为了清楚这一点,wx.CallAfter 可用于从正在运行的线程更新 GUI。这方面的一个例子是 wxPython in Action 一书中的清单 18.5,或者这里的最后一个例子:wiki.wxpython.org/LongRunningTasks
猜你喜欢
  • 2021-12-30
  • 2023-04-04
  • 1970-01-01
  • 2020-01-11
  • 2023-03-10
  • 1970-01-01
  • 2016-02-19
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多