From 5ad6bcd6e49e7e22ed9dba23c7bbdcd03bb90aa4 Mon Sep 17 00:00:00 2001 From: chenhq Date: Fri, 24 Mar 2023 15:15:39 +0800 Subject: [PATCH] v2.5.4 --- .gitignore | 3 + README.md | 19 +- README.zh.md | 18 +- solox/__init__.py | 2 +- solox/debug.py | 1 - solox/public/apm.py | 146 ++++++++------- solox/public/apm_pk.py | 38 ++-- solox/public/common.py | 192 ++++++++++--------- solox/public/fps.py | 51 +---- solox/templates/analysis.html | 4 +- solox/templates/base.html | 85 ++++++--- solox/templates/index.html | 90 +++++---- solox/view/apis.py | 341 ++++++++++++++++++---------------- solox/view/pages.py | 1 + solox/web.py | 10 +- 15 files changed, 520 insertions(+), 481 deletions(-) diff --git a/.gitignore b/.gitignore index 498f347..216ae3e 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dmypy.json # Pyre type checker .pyre/ + +# report +report/ \ No newline at end of file diff --git a/README.md b/README.md index 6d62b32..586a9d5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ We are committed to solving inefficient, cumbersome test execution, and our goal ## Installation ``` -1.Python:3.6+ +1.Python:3.10+ (python3.6+ lower v2.5.3) 2.pip install -U solox 3.pip install -i https://mirrors.ustc.edu.cn/pypi/web/simple -U solox (Recommend) @@ -42,7 +42,7 @@ python -m solox ### customize ```shell -python -m solox --host={ip} --port=50003 +python -m solox --host={ip} --port={port} ``` ## Collect in python @@ -50,13 +50,16 @@ python -m solox --host={ip} --port=50003 from solox.public.apm import APM # solox version >= 2.1.2 -apm = APM(pkgName='com.bilibili.app.in',deviceId='ca6bd5a5',platform='Android', surfaceview='true') # surfaceview: false = gpxinfo +apm = APM(pkgName='com.bilibili.app.in',deviceId='ca6bd5a5',platform='Android', surfaceview=True) # apm = APM(pkgName='com.bilibili.app.in', platform='iOS') only supports one device +# surfaceview: false = gfxinfo (Developer - GPU rendering mode - adb shell dumpsys gfxinfo) + cpu = apm.collectCpu() # % memory = apm.collectMemory() # MB flow = apm.collectFlow() # KB fps = apm.collectFps() # HZ -battery = apm.collectBattery() # level:% temperature:°C +battery = apm.collectBattery() # level:% temperature:°C current:mA voltage:mV power:w +gpu = apm.collectGpu() # % only supports ios ``` ## Collect in API @@ -71,9 +74,10 @@ Windows: start /min python3 -m solox & ### Request apm data from api ``` -http://{ip}:50003/apm/collect?platform=Android&deviceid=ca6bd5a5&pkgname=com.bilibili.app.in&apm_type=cpu +- Android: http://{ip}:{port}/apm/collect?platform=Android&deviceid=ca6bd5a5&pkgname=com.bilibili.app.in&target=cpu +- iOS: http://{ip}:{port}/apm/collect?platform=iOS&pkgname=com.bilibili.app.in&target=cpu -apm_type in ['cpu','memory','network','fps','battery'] +target in ['cpu','memory','network','fps','battery'] ``` ## PK Model @@ -95,6 +99,3 @@ apm_type in ['cpu','memory','network','fps','battery'] - https://github.com/alibaba/taobao-iphone-device -## Communicate -- Email: laoqi1988_f1@126.com - diff --git a/README.zh.md b/README.zh.md index a6d877d..b87eaa3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -27,7 +27,7 @@ SoloX - Android/iOS性能数据实时采集工具。 ## 安装 ``` -1.Python:3.6+ +1.Python:3.6+ (python3.6+ 小于v2.5.3) 2.pip install -U solox 3.pip install -i https://mirrors.ustc.edu.cn/pypi/web/simple -U solox (在国内推荐使用镜像下载) @@ -42,7 +42,7 @@ python -m solox ### 自定义 ```shell -python -m solox --host={ip} --port=50003 +python -m solox --host={ip} --port={port} ``` ## 使用python收集 @@ -50,13 +50,15 @@ python -m solox --host={ip} --port=50003 from solox.public.apm import APM # solox version >= 2.1.2 -apm = APM(pkgName='com.bilibili.app.in',deviceId='ca6bd5a5',platform='Android', surfaceview='true') # surfaceview: false = gpxinfo +apm = APM(pkgName='com.bilibili.app.in',deviceId='ca6bd5a5',platform='Android', surfaceview=True) # apm = APM(pkgName='com.bilibili.app.in', platform='iOS') only supports one device +# surfaceview: false = gfxinfo (手机开发者-GPU呈现模式- adb shell dumpsys gfxinfo) cpu = apm.collectCpu() # % memory = apm.collectMemory() # MB flow = apm.collectFlow() # KB fps = apm.collectFps() # HZ -battery = apm.collectBattery() # level:% temperature:°C +battery = apm.collectBattery() # level:% temperature:°C current:mA voltage:mV power:w +gpu = apm.collectGpu() # % only supports ios ``` ## 使用api收集 @@ -71,9 +73,10 @@ Windows: start /min python3 -m solox & ### 2.通过api请求性能数据 ``` -http://{ip}:50003/apm/collect?platform=Android&deviceid=ca6bd5a5&pkgname=com.bilibili.app.in&apm_type=cpu +- Android: http://{ip}:{port}/apm/collect?platform=Android&deviceid=ca6bd5a5&pkgname=com.bilibili.app.in&target=cpu +- iOS: http://{ip}:{port}/apm/collect?platform=iOS&pkgname=com.bilibili.app.in&target=cpu -apm_type in ['cpu','memory','network','fps','battery'] +target in ['cpu','memory','network','fps','battery'] ``` ## 对比模式 @@ -95,7 +98,4 @@ apm_type in ['cpu','memory','network','fps','battery'] - https://github.com/alibaba/taobao-iphone-device -## 交流 -- Email: laoqi1988_f1@126.com - diff --git a/solox/__init__.py b/solox/__init__.py index 20e4f42..9c4f333 100644 --- a/solox/__init__.py +++ b/solox/__init__.py @@ -2,4 +2,4 @@ from __future__ import absolute_import -__version__ = '2.5.3' +__version__ = '2.5.4' diff --git a/solox/debug.py b/solox/debug.py index 5e359d2..ba06db8 100644 --- a/solox/debug.py +++ b/solox/debug.py @@ -124,7 +124,6 @@ def getServerStatus(host: str, port: int): """ try: r = requests.get(f'http://{host}:{port}', timeout=2.0) - # True和False对应的数值是1和0 flag = (True, False)[r.status_code == 200] return flag except requests.exceptions.ConnectionError: diff --git a/solox/public/apm.py b/solox/public/apm.py index 42e5145..865890d 100644 --- a/solox/public/apm.py +++ b/solox/public/apm.py @@ -1,27 +1,32 @@ import datetime import re import time -import json -from functools import reduce +import os from logzero import logger import tidevice import solox.public._iosPerf as iosP from solox.public.iosperf._perf import DataType, Performance from solox.public.adb import adb -from solox.public.common import Devices, file, Method +from solox.public.common import Devices, file, Method, Platform from solox.public.fps import FPSMonitor, TimeUtils d = Devices() f = file() m = Method() -class CPU: +class Target: + CPU = 'cpu' + Memory = 'memory' + Battery = 'battery' + Network = 'network' + FPS = 'fps' + +class CPU(object): def __init__(self, pkgName, deviceId, platform='Android'): self.pkgName = pkgName self.deviceId = deviceId self.platform = platform - self.apm_time = datetime.datetime.now().strftime('%H:%M:%S.%f') def getprocessCpuStat(self): """get the cpu usage of a process at a certain time""" @@ -35,7 +40,7 @@ def getprocessCpuStat(self): def getTotalCpuStat(self): """get the total cpu usage at a certain time""" - cmd = f'cat /proc/stat |{d._filterType()} ^cpu' + cmd = f'cat /proc/stat |{d.filterType()} ^cpu' result = adb.shell(cmd=cmd, deviceId=self.deviceId) r = re.compile(r'(? 0 try: pid = (0, result[0].split()[1])[flag] @@ -114,37 +119,40 @@ def getPkgnameByiOS(self, udid): pkgNames.append(pkgResult[i].split(' ')[0]) return pkgNames - def _devicesCheck(self, pf, id='', pkg=''): + def devicesCheck(self, platform, deviceid=None, pkgname=None): """Check the device environment""" - if pf == 'Android': - if len(self.getDeviceIds()) == 0: - raise Exception('no devices') - if not self.getPid(deviceId=id, pkgName=pkg): - raise Exception('no found app process') - elif pf == 'iOS': - if len(self.getDeviceInfoByiOS()) == 0: - raise Exception('no devices') - else: - raise Exception('platform must be Android or iOS') - + match(platform): + case Platform.Android: + if len(self.getDeviceIds()) == 0: + raise Exception('no devices') + if not self.getPid(deviceId=deviceid, pkgName=pkgname): + raise Exception('no found app process') + case Platform.iOS: + if len(self.getDeviceInfoByiOS()) == 0: + raise Exception('no devices') + case _: + raise Exception('platform must be Android or iOS') + def getDdeviceDetail(self, deviceId, platform): result = {} - if platform == 'Android': - result['brand'] = adb.shell(cmd='getprop ro.product.brand', deviceId=deviceId) - result['name'] = adb.shell(cmd='getprop ro.product.model', deviceId=deviceId) - result['version'] = adb.shell(cmd='getprop ro.build.version.release', deviceId=deviceId) - result['serialno'] = adb.shell(cmd='getprop ro.serialno', deviceId=deviceId) - cmd = f'ip addr show wlan0 | {self._filterType()} link/ether' - result['wifiadr'] = adb.shell(cmd=cmd, deviceId=deviceId).split(' ')[1] - elif platform == 'iOS': - iosInfo = json.loads(self.execCmd('tidevice info --json')) - result['brand'] = iosInfo['DeviceClass'] - result['name'] = iosInfo['DeviceName'] - result['version'] = iosInfo['ProductVersion'] - result['serialno'] = iosInfo['SerialNumber'] - result['wifiadr'] = iosInfo['WiFiAddress'] - return result - + match(platform): + case Platform.Android: + result['brand'] = adb.shell(cmd='getprop ro.product.brand', deviceId=deviceId) + result['name'] = adb.shell(cmd='getprop ro.product.model', deviceId=deviceId) + result['version'] = adb.shell(cmd='getprop ro.build.version.release', deviceId=deviceId) + result['serialno'] = adb.shell(cmd='getprop ro.serialno', deviceId=deviceId) + cmd = f'ip addr show wlan0 | {self.filterType()} link/ether' + result['wifiadr'] = adb.shell(cmd=cmd, deviceId=deviceId).split(' ')[1] + case Platform.iOS: + iosInfo = json.loads(self.execCmd('tidevice info --json')) + result['brand'] = iosInfo['DeviceClass'] + result['name'] = iosInfo['DeviceName'] + result['version'] = iosInfo['ProductVersion'] + result['serialno'] = iosInfo['SerialNumber'] + result['wifiadr'] = iosInfo['WiFiAddress'] + case _: + raise Exception('{} is undefined'.format(platform)) + return result class file: @@ -256,10 +264,11 @@ def getCpuLog(self, platform, scene): def getMemLog(self, platform, scene): targetDic = {} targetDic['memTotalData'] = self.readLog(scene=scene, filename='mem_total.log')[0] - if platform == 'Android': + if platform == Platform.Android: targetDic['memNativeData'] = self.readLog(scene=scene, filename='mem_native.log')[0] targetDic['memDalvikData'] = self.readLog(scene=scene, filename='mem_dalvik.log')[0] - result = {'status': 1, 'memTotalData': targetDic['memTotalData'], + result = {'status': 1, + 'memTotalData': targetDic['memTotalData'], 'memNativeData': targetDic['memNativeData'], 'memDalvikData': targetDic['memDalvikData']} else: @@ -268,17 +277,22 @@ def getMemLog(self, platform, scene): def getBatteryLog(self, platform, scene): targetDic = {} - if platform == 'Android': + if platform == Platform.Android: targetDic['batteryLevel'] = self.readLog(scene=scene, filename='battery_level.log')[0] targetDic['batteryTem'] = self.readLog(scene=scene, filename='battery_tem.log')[0] - result = {'status': 1, 'batteryLevel': targetDic['batteryLevel'], 'batteryTem': targetDic['batteryTem']} + result = {'status': 1, + 'batteryLevel': targetDic['batteryLevel'], + 'batteryTem': targetDic['batteryTem']} else: targetDic['batteryTem'] = self.readLog(scene=scene, filename='battery_tem.log')[0] targetDic['batteryCurrent'] = self.readLog(scene=scene, filename='battery_current.log')[0] targetDic['batteryVoltage'] = self.readLog(scene=scene, filename='battery_voltage.log')[0] targetDic['batteryPower'] = self.readLog(scene=scene, filename='battery_power.log')[0] - result = {'status': 1, 'batteryTem': targetDic['batteryTem'], 'batteryCurrent': targetDic['batteryCurrent'], - 'batteryVoltage': targetDic['batteryVoltage'], 'batteryPower': targetDic['batteryPower']} + result = {'status': 1, + 'batteryTem': targetDic['batteryTem'], + 'batteryCurrent': targetDic['batteryCurrent'], + 'batteryVoltage': targetDic['batteryVoltage'], + 'batteryPower': targetDic['batteryPower']} return result def getFlowLog(self, platform, scene): @@ -291,7 +305,7 @@ def getFlowLog(self, platform, scene): def getFpsLog(self, platform, scene): targetDic = {} targetDic['fps'] = self.readLog(scene=scene, filename='fps.log')[0] - if platform == 'Android': + if platform == Platform.Android: targetDic['jank'] = self.readLog(scene=scene, filename='jank.log')[0] result = {'status': 1, 'fps': targetDic['fps'], 'jank': targetDic['jank']} else: @@ -356,21 +370,20 @@ def _setAndroidPerfs(self, scene): flowRecvData = self.readLog(scene=scene, filename=f'downflow.log')[1] flowRecv = f'{round(float(sum(flowRecvData) / 1024), 2)}MB' - - apm_dict = { - "cpuAppRate": cpuAppRate, - "cpuSystemRate": cpuSystemRate, - "totalPassAvg": totalPassAvg, - "nativePassAvg": nativePassAvg, - "dalvikPassAvg": dalvikPassAvg, - "fps": fpsAvg, - "jank": jankAvg, - "flow_send": flowSend, - "flow_recv": flowRecv, - "batteryLevel": batteryLevel, - "batteryTeml": batteryTeml - } - + + apm_dict = {} + apm_dict['cpuAppRate'] = cpuAppRate + apm_dict['cpuSystemRate'] = cpuSystemRate + apm_dict['totalPassAvg'] = totalPassAvg + apm_dict['nativePassAvg'] = nativePassAvg + apm_dict['dalvikPassAvg'] = dalvikPassAvg + apm_dict['fps'] = fpsAvg + apm_dict['jank'] = jankAvg + apm_dict['flow_send'] = flowSend + apm_dict['flow_recv'] = flowRecv + apm_dict['batteryLevel'] = batteryLevel + apm_dict['batteryTeml'] = batteryTeml + return apm_dict def _setiOSPerfs(self, scene): @@ -405,23 +418,21 @@ def _setiOSPerfs(self, scene): batteryPowerData = self.readLog(scene=scene, filename=f'battery_power.log')[1] batteryPower = round(sum(batteryPowerData) / len(batteryPowerData), 2) - - apm_dict = { - "cpuAppRate": cpuAppRate, - "cpuSystemRate": cpuSystemRate, - "totalPassAvg": totalPassAvg, - "nativePassAvg": 0, - "dalvikPassAvg": 0, - "fps": fpsAvg, - "jank": 0, - "flow_send": flowSend, - "flow_recv": flowRecv, - "batteryTeml": batteryTeml, - "batteryCurrent": batteryCurrent, - "batteryVoltage": batteryVoltage, - "batteryPower": batteryPower - } - + apm_dict = {} + apm_dict['cpuAppRate'] = cpuAppRate + apm_dict['cpuSystemRate'] = cpuSystemRate + apm_dict['totalPassAvg'] = totalPassAvg + apm_dict['nativePassAvg'] = 0 + apm_dict['dalvikPassAvg'] = 0 + apm_dict['fps'] = fpsAvg + apm_dict['jank'] = 0 + apm_dict['flow_send'] = flowSend + apm_dict['flow_recv'] = flowRecv + apm_dict['batteryTeml'] = batteryTeml + apm_dict['batteryCurrent'] = batteryCurrent + apm_dict['batteryVoltage'] = batteryVoltage + apm_dict['batteryPower'] = batteryPower + return apm_dict def _setpkPerfs(self, scene): @@ -445,30 +456,29 @@ def _setpkPerfs(self, scene): network1 = f'{round(float(sum(networkData1) / 1024), 2)}MB' networkData2 = self.readLog(scene=scene, filename=f'network2.log')[1] network2 = f'{round(float(sum(networkData2) / 1024), 2)}MB' - - apm_dict = { - "cpuAppRate1": cpuAppRate1, - "cpuAppRate2": cpuAppRate2, - "totalPassAvg1": totalPassAvg1, - "totalPassAvg2": totalPassAvg2, - "network1": network1, - "network2": network2, - "fpsAvg1": fpsAvg1, - "fpsAvg2": fpsAvg2 - } - + + apm_dict = {} + apm_dict['cpuAppRate1'] = cpuAppRate1 + apm_dict['cpuAppRate2'] = cpuAppRate2 + apm_dict['totalPassAvg1'] = totalPassAvg1 + apm_dict['totalPassAvg2'] = totalPassAvg2 + apm_dict['network1'] = network1 + apm_dict['network2'] = network2 + apm_dict['fpsAvg1'] = fpsAvg1 + apm_dict['fpsAvg2'] = fpsAvg2 return apm_dict class Method: def _request(self, request, object): - if request.method == 'POST': - return request.form[object] - elif request.method == 'GET': - return request.args[object] - else: - raise Exception('request method error') + match(request.method): + case 'POST': + return request.form[object] + case 'GET': + return request.args[object] + case _: + raise Exception('request method error') def _setValue(self, value): try: diff --git a/solox/public/fps.py b/solox/public/fps.py index 8be6f15..b452397 100644 --- a/solox/public/fps.py +++ b/solox/public/fps.py @@ -16,9 +16,6 @@ class SurfaceStatsCollector(object): - """Collects surface stats for a SurfaceView from the output of SurfaceFlinger - """ - def __init__(self, device, frequency, package_name, fps_queue, jank_threshold, surfaceview, use_legacy=False): self.device = device self.frequency = frequency @@ -57,8 +54,6 @@ def start(self, start_time): self.calculator_thread.start() def stop(self): - """结束SurfaceStatsCollector - """ if self.collector_thread: self.stop_event.set() self.collector_thread.join() @@ -67,10 +62,9 @@ def stop(self): self.fps_queue.task_done() def get_surfaceview_activity(self): - """兼容不同设备的surfaceview""" activity_name = '' activity_line = '' - dumpsys_result = adb.shell(cmd='dumpsys SurfaceFlinger --list | {} {}'.format(d._filterType(), self.package_name), deviceId=self.device) + dumpsys_result = adb.shell(cmd='dumpsys SurfaceFlinger --list | {} {}'.format(d.filterType(), self.package_name), deviceId=self.device) dumpsys_result_list = dumpsys_result.split('\n') for line in dumpsys_result_list: if line.startswith('SurfaceView') and line.find(self.package_name) != -1: @@ -87,7 +81,6 @@ def get_surfaceview_activity(self): return activity_name def get_focus_activity(self): - """通过dumpsys window windows获取activity名称 window名""" activity_name = '' activity_line = '' dumpsys_result = adb.shell(cmd='dumpsys window windows', deviceId=self.device) @@ -109,9 +102,6 @@ def get_focus_activity(self): return activity_name def get_foreground_process(self): - """ - :return: 当前前台进程名,对get_focus_activity的返回结果加以处理 - """ focus_activity = self.get_focus_activity() if focus_activity: return focus_activity.split("/")[0] @@ -119,10 +109,6 @@ def get_foreground_process(self): return "" def _calculate_results(self, refresh_period, timestamps): - """Returns a list of SurfaceStatsCollector.Result. - 不少手机第一列 第三列 数字完全相同 - """ - frame_count = len(timestamps) if frame_count == 0: fps = 0 @@ -141,10 +127,6 @@ def _calculate_results(self, refresh_period, timestamps): return fps, jank def _calculate_results_new(self, refresh_period, timestamps): - """Returns a list of SurfaceStatsCollector.Result. - 不少手机第一列 第三列 数字完全相同 - """ - frame_count = len(timestamps) if frame_count == 0: fps = 0 @@ -392,7 +374,7 @@ def _get_surfaceflinger_frame_data(self): timestamps = [] nanoseconds_per_second = 1e9 pending_fence_timestamp = (1 << 63) - 1 - if self.surfaceview != 'true': + if self.surfaceview is not True: results = adb.shell( cmd='dumpsys SurfaceFlinger --latency %s' % self.focus_window, deviceId=self.device) results = results.replace("\r\n", "\n").splitlines() @@ -480,32 +462,20 @@ def _get_surface_stats_legacy(self): class Monitor(object): - """性能测试数据采集能力基类 - """ - def __init__(self, **kwargs): - """构造器 - - :param dict kwargs: 配置项 - """ self.config = kwargs # 配置项 self.matched_data = {} # 采集到匹配的性能数据 def start(self): - """子类中实现该接口,开始采集性能数据""" logger.warn("请在%s类中实现start方法" % type(self)) def clear(self): - """清空monitor保存的数据""" self.matched_data = {} def stop(self): - """子类中实现该接口,结束采集性能数据,如果后期需要解析性能数据,需要保存数据文件""" logger.warning("请在%s类中实现stop方法" % type(self)) def save(self): - """保存数据 - """ logger.warning("请在%s类中实现save方法" % type(self)) @@ -521,10 +491,8 @@ def getCurrentTimeUnderline(): class FPSMonitor(Monitor): - """FPS监控器""" - def __init__(self, device_id, package_name=None, frequency=1.0, timeout=24 * 60 * 60, fps_queue=None, - jank_threshold=166, use_legacy=False, surfaceview='true', start_time=None, **kwargs): + jank_threshold=166, use_legacy=False, surfaceview=True, start_time=None, **kwargs): """ 构造器 :param str device_id: 设备id @@ -541,7 +509,6 @@ def __init__(self, device_id, package_name=None, frequency=1.0, timeout=24 * 60 self.device = device_id self.timeout = timeout self.surfaceview = surfaceview - # todo 判断是否为当前启动的进程 if not package_name: package_name = self.device.adb.get_foreground_process() self.package = package_name @@ -549,13 +516,9 @@ def __init__(self, device_id, package_name=None, frequency=1.0, timeout=24 * 60 self.jank_threshold, self.surfaceview, self.use_legacy) def start(self): - """启动FPSMonitor日志监控器 - """ self.fpscollector.start(self.start_time) def stop(self): - """结束FPSMonitor日志监控器 - """ global collect_fps global collect_jank self.fpscollector.stop() @@ -565,15 +528,7 @@ def save(self): pass def parse(self, file_path): - """解析 - :param str file_path: 要解析数据文件的路径 - """ pass def get_fps_collector(self): - """获得fps收集器,收集器里保存着time fps jank的列表 - - :return: fps收集器 - :rtype: SurfaceStatsCollector - """ return self.fpscollector diff --git a/solox/templates/analysis.html b/solox/templates/analysis.html index 36ef18c..ec6093f 100644 --- a/solox/templates/analysis.html +++ b/solox/templates/analysis.html @@ -722,10 +722,10 @@ }, success: function (data) { network_chart.updateSeries([{ - name: 'upflow', + name: 'send', data: data['upFlow'] },{ - name: 'downflow', + name: 'recv', data: data['downFlow'] }]) } diff --git a/solox/templates/base.html b/solox/templates/base.html index 5e2c3af..5099aef 100644 --- a/solox/templates/base.html +++ b/solox/templates/base.html @@ -137,7 +137,7 @@

  • - {% if lan == 'cn' %} 版本 {% else %} Releases {% endif %} . V2.5.3 + {% if lan == 'cn' %} 版本 {% else %} Releases {% endif %} . V2.5.4
  • @@ -155,36 +155,62 @@

    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - +
    + +
    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    -
    - - +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    -
    - - +
    + + {% if lan == 'cn' %} 保存 {% else %} Save {% endif %}
    @@ -313,7 +339,8 @@