提交 2d61c8f8 编写于 作者: B BingBlog

add histograms

上级 7173540f
此差异已折叠。
/**
* get mock data
*
* @param {string} path request path
* @param {Object} queryParam query params
* @param {Object} postParam post params
* @return {Object}
*/
module.exports = function (path, queryParam, postParam) {
return {
// moock delay
_timeout: 0,
// mock http status
_status: 200,
// mock response data
_data: {
status: 0,
msg: 'SUCCESS',
data: {
"test": {
"layer1/Wx_plus_b/pre_activations":
{
"displayName": "layer1/Wx_plus_b/pre_activations",
"description": ""
}
}
}
}
};
};
......@@ -165,10 +165,6 @@ module.exports = function (path, queryParam, postParam) {
"layer1/biases/summaries/stddev_1": {
"displayName": "layer1/biases/summaries/stddev_1",
"description": ""
},
"layer1/biases/summaries/min": {
"displayName": "layer1/biases/summaries/min",
"description": ""
}
}
}
......
......@@ -13,8 +13,12 @@
},
"dependencies": {
"axios": "^0.16.1",
"csshint": "^0.3.3",
"d3-format": "^1.2.1",
"echarts": "^3.8.5",
"file-saver": "^1.3.3",
"htmlcs": "^0.4.1",
"lesslint": "^1.0.2",
"lodash": "^4.17.4",
"normalize.css": "^6.0.0",
"qs": "^6.5.1",
......@@ -43,7 +47,7 @@
"css-loader": "^0.28.0",
"express": "^4.16.2",
"extract-text-webpack-plugin": "^2.1.0",
"fecs": "^1.5.3",
"fecs": "1.5.2",
"file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.6.1",
"glob": "^7.1.1",
......
<template>
<div id="app">
<ui-app-menu
selected="{{route}}"
on-item-click="menuChange($event)"
></ui-app-menu>
<div id="content-container" class="visual-dl-content-container">
......@@ -21,10 +22,20 @@ export default {
'ui-app-menu': AppMenu
},
initData() {
return {};
return {
route: 'scalars'
};
},
attached() {
router.start();
let route;
if (location.hash) {
route = /(\#\/)(\w*)([?|&]{0,1})/.exec(location.hash)[2];
this.data.set('route', route);
}
else {
location.hash = '#/scalars';
}
},
menuChange({value, url, title}) {
routeTo(url);
......
......@@ -4,7 +4,7 @@
<san-menu slot="right">
<san-menu-item
san-for="item in items"
class="{{selected === item.value ? 'sm-menu-item-selected' : ''}}"
class="{{selected === item.name ? 'sm-menu-item-selected' : ''}}"
on-click="handleItemClick(item)"
title="{{item.title}}" />
</san-menu>
......@@ -23,23 +23,28 @@ export default {
},
initData() {
return {
selected: '1',
selected: 'scalars',
items: [
{
value: '1',
url: '/scalars',
title: 'SCALARS'
title: 'SCALARS',
name: 'scalars'
},
{
value: '2',
url: '/images',
title: 'IMAGES'
title: 'IMAGES',
name: 'images'
},
{
url: '/histograms',
title: 'HISTOGRAMS',
name: 'histograms'
}
]
};
},
handleItemClick(item) {
this.data.set('selected', item.value);
this.data.set('selected', item.name);
this.fire('item-click', item);
}
};
......@@ -61,7 +66,16 @@ export default {
height 100%
display flex
flex-direction row
.sm-menu-item
.sm-menu-item-content
color #fff
opacity 0.6
.sm-menu-item:hover
background none
opacity 1
.sm-menu-item-selected
background #e4e4e4
.sm-menu-item-content
color #fff
opacity 1
</style>
......@@ -457,7 +457,6 @@ export default {
height: 300
});
}
},
getFormatterPoints(data) {
......
......@@ -41,13 +41,11 @@ export default {
if (isActualImageSize) {
width = this.data.get('imgData.width');
height = this.data.get('imgData.height');
console.log('1111', width, height);
}
else {
width = defaultImgWidth;
height = defaultImgHight;
}
// console.log('width:' + width + 'px;height:' + height + 'px');
return 'width:' + width + 'px;height:' + height + 'px';
}
},
......@@ -101,17 +99,6 @@ export default {
});
/* eslint-enable fecs-camelcase */
});
// isActualImageSize change event
this.watch('isActualImageSize', isActualImageSize => {
console.log(isActualImageSize);
});
// isActualImageSize change event
this.watch('runs', runs => {
console.log(runs);
});
},
handleSlideChange(val) {
this.data.set('currentIndex', val);
......
<template>
<div class="visual-dl-scalar-container">
<div class="visual-dl-scalar-left">
<div class="visual-dl-scalar-config-container">
<ui-config
runsItems="{{runsItems}}"
config="{=config=}"
></ui-config>
</div>
</div>
<div class="visual-dl-scalar-right">
<ui-chart-page
config="{{config}}"
runsItems="{{runsItems}}"
tagList="{{filteredTagsList}}"
title="Tags matching {{config.groupNameReg}}"
></ui-chart-page>
<ui-chart-page
san-for="item in groupedTags"
config="{{config}}"
runsItems="{{runsItems}}"
tagList="{{item.tags}}"
title="{{item.group}}"
></ui-chart-page>
</div>
</div>
</template>
<script>
import {getPluginHistogramsTags, getRuns} from '../service';
import config from './ui/config';
import chartPage from './ui/chartPage';
import {debounce, flatten, uniq} from 'lodash';
export default {
components: {
'ui-config': config,
'ui-chart-page': chartPage
},
computed: {
runsItems() {
let runsArray = this.data.get('runsArray') || [];
return runsArray.map(item => {
return {
name: item,
value: item
};
});
},
tagsList() {
let tags = this.data.get('tags');
let runs = Object.keys(tags);
let tagsArray = runs.map(run => Object.keys(tags[run]));
let allUniqTags = uniq(flatten(tagsArray));
// get the data for every chart
return allUniqTags.map(tag => {
let tagList = runs.map(run => {
return {
run,
tag: tags[run][tag]
};
});
return {
tagList,
tag,
group: tag.split('/')[0]
};
});
},
groupedTags() {
let tagsList = this.data.get('tagsList') || [];
// put data in group
let groupData = {};
tagsList.forEach(item => {
let group = item.group;
if (groupData[group] === undefined) {
groupData[group] = [];
groupData[group].push(item);
}
else {
groupData[group].push(item);
}
});
// to array
let groups = Object.keys(groupData);
return groups.map(group => {
return {
group,
tags: groupData[group]
};
});
}
// ,
// filteredConfig() {
// let config = this.data.get('config') || {};
// let filteredConfig = {};
// Object.keys(config).forEach(key => {
// let val = config[key];
// filteredConfig[key] = val;
// });
// return filteredConfig;
// }
},
initData() {
return {
runsArray: [],
tags: [],
config: {
groupNameReg: '.*',
horizontal: 'step',
chartType: 'offset',
runs: [],
running: true
}
};
},
inited() {
getPluginHistogramsTags().then(({errno, data}) => {
this.data.set('tags', data);
// filter when inited
let groupNameReg = this.data.get('config.groupNameReg');
this.filterTagsList(groupNameReg);
});
getRuns().then(({errno, data}) => {
this.data.set('runsArray', data);
this.data.set('config.runs', data);
});
// need debounce, can't use computed
this.watch('config.groupNameReg', debounce(this.filterTagsList, 300));
},
filterTagsList(groupNameReg) {
let tagsList = this.data.get('tagsList') || [];
let regExp = new RegExp(groupNameReg);
let filtedTagsList = tagsList.filter(item => regExp.test(item.tag));
this.data.set('filteredTagsList', filtedTagsList);
}
};
</script>
<style lang="stylus">
@import '../style/variables';
+prefix-classes('visual-dl-scalar-')
.container
padding-left 300px
position relative
.left
width 280px
min-height 300px
border solid 1px #e4e4e4
position absolute
left 0
.right
width 100%
border solid 1px #e4e4e4
min-height 300px
padding 20px
</style>
import {min, max, range} from 'lodash';
export const tansformHistogramData = hitogramData => {
let [time, step, items] = hitogramData;
return {
time,
step,
min: min(items.map(([left, right, count]) => left)),
max: max(items.map(([left, right, count]) => right)),
items: items.map(([left, right, count]) => ({left, right, count}))
};
};
export const computeTempDatas = (histogram, min, max, numbers = 30) => {
if (max === min) {
// Create bins even if all the data has a single value.
max = min * 1.1 + 1;
min = min / 1.1 - 1;
}
let stepWidth = (max - min) / numbers;
let index = 0;
return range(min, max, stepWidth).map(left => {
let right = left + stepWidth;
let yValue = 0;
while (index < histogram.items.length) {
let itemRight = Math.min(max, histogram.items[index].right);
let itemLeft = Math.max(min, histogram.items[index].left);
let intersect = Math.min(itemRight, right) - Math.max(itemLeft, left);
let count = (intersect / (itemRight - itemLeft))
* histogram.items[index].count;
yValue += intersect > 0 ? count : 0;
// If `bucketRight` is bigger than `binRight`, then this bin is
// finished and there is data for the next bin, so don't increment
// `index`.
if (itemRight > right) {
break;
}
index++;
}
return {x: left, dx: stepWidth, y: yValue};
});
};
export const tansformToChartData
= (tempData, time, step) => tempData.map(({x, dx, y}) => [time, step, x + dx / 2, Math.floor(y)]);
export const originDataToChartData = originData => {
let tempDatas = originData.map(tansformHistogramData);
let finalMin = min(tempDatas.map(({min}) => min));
let finalMax = max(tempDatas.map(({max}) => max));
let chartData = tempDatas.map(item => {
let computedTempDatas = computeTempDatas(item, finalMin, finalMax);
let {time, step} = item;
return {
time,
step,
items: tansformToChartData(computedTempDatas, time, step)
};
});
return {
min: finalMin,
max: finalMax,
chartData
};
};
import {router} from 'san-router';
import Histogram from './Histogram';
router.add({
target: '#content',
rule: '/histograms',
Component: Histogram
});
<template>
<div class="visual-dl-charts">
<div class="visual-dl-chart-box">
</div>
<div class="visual-dl-chart-actions">
<san-button on-click="expandArea">
<san-icon size="20">settings_overscan</san-icon>
</san-button>
</div>
</div>
</template>
<script>
// components
import Button from 'san-mui/Button';
import Icon from 'san-mui/Icon';
// libs
import echarts from 'echarts';
import {originDataToChartData} from '../histogramHelper';
import {format, precisionRound} from 'd3-format';
// service
import {getPluginHistogramsHistograms} from '../../service';
const highlightLineColor = '#2f4554';
const defaultLineColor = '#fec42c';
const lineWidth = 1;
// the time to refresh chart data
const intervalTime = 30;
const p = Math.max(0, precisionRound(0.01, 1.01) - 1);
const yValueFormat = format('.' + p + 'e');
export default {
components: {
'san-button': Button,
'san-icon': Icon
},
computed: {
},
initData() {
return {
width: 400,
height: 300,
data: [
{
name: 'train',
value: []
}
]
};
},
inited() {
this.watch('originData', data => {
this.initChartChartOption();
});
this.watch('chartType', chartType => {
this.initChartChartOption();
});
},
attached() {
let tagInfo = this.data.get('tagInfo');
this.initCharts(tagInfo);
this.initeChartsEvent();
if (this.data.get('running')) {
this.startInterval();
}
this.watch('running', running => {
running ? this.startInterval() : this.stopInterval();
});
},
detached() {
this.stopInterval();
},
initCharts(tagInfo) {
this.createChart();
// this.setChartsOptions(tagInfo);
this.getOriginChartsData(tagInfo);
},
createChart() {
let el = this.el.getElementsByClassName('visual-dl-chart-box')[0];
this.myChart = echarts.init(el);
},
initChartChartOption() {
this.myChart.clear();
let data = this.data.get('originData');
let chartData = originDataToChartData(data);
let tagInfo = this.data.get('tagInfo');
let title = tagInfo.tag.displayName + '(' + tagInfo.run + ')';
let chartType = this.data.get('chartType');
this.setChartOptions(chartData, title, chartType);
},
setChartOptions(chartData, tag, chartType) {
let grid = {
left: '15%',
top: '20%',
right: '10%',
bottom: '20%'
};
let title = {
text: tag,
textStyle: {
fontSize: '12'
}
};
if (chartType === 'overlay') {
this.setOverlayChartOption(chartData, title, grid);
}
else if (chartType === 'offset') {
this.setOffsetChartOption(chartData, title, grid);
}
},
setOverlayChartOption({chartData, min, max}, title, grid) {
let seriesOption = chartData.map(({time, step, items}) => ({
name: 'step' + step,
type: 'line',
showSymbol: false,
hoverAnimation: false,
z: 0,
data: items,
animationDuration: 100,
lineStyle: {
normal: {
// opacity: originLinesOpacity,
width: lineWidth,
color: defaultLineColor
}
},
encode: {
x: [2],
y: [3]
}
})
);
let option = {
title: title,
axisPointer: {
link: {xAxisIndex: 'all'},
show: true,
snap: true,
triggerTooltip: true
},
grid: grid,
xAxis: {
type: 'value'
},
yAxis: {
type: 'value',
axisLabel: {
formatter(value, index) {
return yValueFormat(value);
}
},
axisPointer: {
label: {
formatter({value}) {
return yValueFormat(value);
}
}
}
},
series: seriesOption
};
this.myChart.setOption(option);
},
setOffsetChartOption({chartData, min, max}, title, grid) {
let rawData = [];
let minX = min;
let maxX = max;
let minZ = Infinity;
let maxZ = -Infinity;
grid.top = '60%';
chartData.forEach(({items}) => {
let lineData = [];
items.forEach(([time, step, x, y]) => {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minZ = Math.min(minZ, y);
maxZ = Math.max(maxZ, y);
lineData.push(x, step, y);
});
rawData.push(lineData);
});
// Max height in z axis.
let Z_HEIGHT = 100;
let option = {
title,
visualMap: {
type: 'continuous',
show: false,
min: 0,
max: 1000,
dimension: 1,
inRange: {
colorLightness: [0.3, 0.7]
}
},
xAxis: {
// min: minX - (maxX - minX) * 0.3,
// max: maxX + (maxX - minX) * 0.3,
min: minX,
max: maxX,
axisLine: {
onZero: false
},
splitLine: {
show: false
}
},
yAxis: {
position: 'right',
inverse: true,
splitLine: {
show: false
}
},
grid,
series: [{
type: 'custom',
dimensions: ['x', 'y'],
renderItem(params, api) {
let points = [];
for (let i = 0; i < rawData[params.dataIndex].length;) {
let x = api.value(i++);
let y = api.value(i++);
let z = api.value(i++);
let pt = api.coord([x, y]);
// Linear map in z axis
pt[1] -= (z - minZ) / (maxZ - minZ) * Z_HEIGHT;
points.push(pt);
}
return {
type: 'polygon',
shape: {
points
},
style: api.style({
stroke: '#bbb',
lineWidth: 1
})
};
},
data: rawData
}]
};
this.myChart.setOption(option);
},
// get origin data per 60 seconds
startInterval() {
this.getOringDataInterval = setInterval(() => {
let tagInfo = this.data.get('tagInfo');
this.getOriginChartsData(tagInfo);
}, intervalTime * 1000);
},
stopInterval() {
clearInterval(this.getOringDataInterval);
},
getOriginChartsData({run, tag}) {
let params = {
run,
tag: tag.displayName
};
getPluginHistogramsHistograms(params).then(({data}) => {
this.data.set('originData', data);
});
},
lightHoveredLine({seriesIndex}) {
let series = this.myChart.getOption().series;
let newSeries = series.map((item, index) => {
if (seriesIndex === index) {
item.lineStyle.normal.color = highlightLineColor;
item.zlevel = 1;
}
else {
item.lineStyle.normal.color = defaultLineColor;
item.zlevel = 0;
}
return item;
});
this.myChart.setOption({
series: newSeries
});
},
initeChartsEvent() {
this.myChart.on('mousemove', params => {
let chartType = this.data.get('chartType');
if (chartType === 'overlay') {
this.lightHoveredLine(params);
}
});
},
expandArea() {
let isExpand = this.data.get('isExpand');
if (!isExpand) {
let el = this.el.getElementsByClassName('visual-dl-chart-box')[0];
el.style.width = '800px';
el.style.height = '600px';
this.data.set('isExpand', true);
this.myChart.resize({
width: 800,
height: 600
});
}
else {
let el = this.el.getElementsByClassName('visual-dl-chart-box')[0];
el.style.width = '400px';
el.style.height = '300px';
this.data.set('isExpand', false);
this.myChart.resize({
width: 400,
height: 300
});
}
}
};
</script>
<style lang="stylus">
.visual-dl-charts
float left
margin-bottom 20px
.visual-dl-chart-box
width 400px;
height 300px;
.visual-dl-chart-actions
height 50px
margin-left 10%
.sm-form-item
float left
width 100px
margin-top 0px
display block
.sm-button
float left
display block
height 20px
line-height 20px
margin-top 10px
padding 0 10px
</style>
<template>
<div class="visual-dl-chart-page">
<ui-expand-panel info="{{itemsLength}}" title="{{title}}">
<div san-for="tag in filteredPageList" class="visual-dl-chart-box">
<ui-chart
san-for="tag in tag.tagList"
tagInfo="{{tag}}"
runs="{{config.runs}}"
chartType="{{config.chartType}}"
runsItems="{{runsItems}}"
></ui-chart>
</div>
<ui-pagination
san-if="total > pageSize"
on-pageChange="handlePageChange($event)"
current="{{currentPage}}"
pageSize="{{pageSize}}"
total="{{total}}"
showSizeChanger="{{false}}"
/>
</ui-expand-panel>
</div>
</template>
<script>
import ExpandPanel from '../../common/component/ExpandPanel';
import Chart from './chart';
import Pagination from 'san-mui/Pagination';
import {cloneDeep} from 'lodash';
export default {
components: {
'ui-chart': Chart,
'ui-expand-panel': ExpandPanel,
'ui-pagination': Pagination
},
computed: {
filteredRunsList() {
let tagList = this.data.get('tagList') || [];
let runs = this.data.get('config.runs') || [];
let list = cloneDeep(tagList);
return list.slice().map(item => {
item.tagList = item.tagList.filter(one => runs.includes(one.run));
return item;
});
},
filteredPageList() {
let list = this.data.get('filteredRunsList');
let currentPage = this.data.get('currentPage');
let pageSize = this.data.get('pageSize');
return list.slice((currentPage - 1) * pageSize, currentPage * pageSize);
},
total() {
let list = this.data.get('filteredRunsList') || [];
return list.reduce((num, item) => {
let length = item.tagList.length;
return length ? num + 1 : num;
}, 0);
},
itemsLength() {
let list = this.data.get('filteredRunsList') || [];
return list.reduce((num, item) => {
let length = item.tagList.length;
return length + num;
}, 0);
}
},
initData() {
return {
// current page
currentPage: 1,
// item per page
pageSize: 4
};
},
handlePageChange({pageNum}) {
this.data.set('currentPage', pageNum);
}
};
</script>
<style lang="stylus">
@import '../../style/variables';
+prefix-classes('visual-dl-')
.chart-page
.chart-box
float left
.chart-box:after
content: "";
clear: both;
display: block;
</style>
<template>
<div class="visual-dl-scalar-config-com">
<san-text-field
hintText="input a tag group name to search"
label="Group name RegExp"
inputValue="{=config.groupNameReg=}"
/>
<ui-radio-group
label="Histogram mode"
value="{=config.chartType=}"
items="{{charTypeItems}}"
/>
<ui-radio-group
label="Horizontal"
value="{=config.horizontal=}"
items="{{horizontalItems}}"
/>
<ui-checkbox-group
value="{=config.runs=}"
label="Runs"
items="{{runsItems}}"
/>
<san-button
class="visual-dl-scalar-run-toggle"
variants="raised {{config.running ? 'secondery' : 'primary'}}"
on-click="toggleAllRuns"
>
{{config.running ? 'Running' : 'Stopped'}}
</san-button>
</div>
</template>
<script>
import TextField from 'san-mui/TextField';
import Slider from '../../common/component/Slider';
import RadioGroup from '../../common/component/RadioGroup';
import DropDownMenu from '../../common/component/DropDownMenu';
import CheckBoxGroup from '../../common/component/CheckBoxGroup';
import Button from 'san-mui/Button';
export default {
components: {
'san-text-field': TextField,
'ui-slider': Slider,
'ui-radio-group': RadioGroup,
'ui-dropdown-menu': DropDownMenu,
'ui-checkbox-group': CheckBoxGroup,
'san-button': Button
},
initData() {
return {
config: {
groupNameReg: '.*',
smoothing: '0.6',
horizontal: 'step',
sortingMethod: 'default',
downloadLink: [],
outlier: [],
running: true
},
horizontalItems: [
{
name: 'Step',
value: 'step'
},
{
name: 'Relative',
value: 'relative'
},
{
name: 'Wall',
value: 'wall'
}
],
runsItems: [],
charTypeItems: [
{
name: 'Overlay',
value: 'overlay'
},
{
name: 'Offset',
value: 'offset'
}
]
};
},
toggleAllRuns() {
let running = this.data.get('config.running');
this.data.set('config.running', !running);
this.fire('runningChange', running);
}
};
</script>
<style lang="stylus">
@import '../../style/variables';
+prefix-classes('visual-dl-scalar-')
.config-com
width 90%
margin 0 auto
.run-toggle
width 100%
margin-top 20px
</style>
......@@ -4,6 +4,6 @@ import HomePage from './Home';
router.add({
target: '#content',
rule: '/',
rule: '/welcome',
Component: HomePage
});
......@@ -95,7 +95,6 @@ export default {
filteredConfig() {
let tansformArr = ['isActualImageSize'];
let config = this.data.get('config') || {};
console.log(config);
let filteredConfig = {};
Object.keys(config).forEach(key => {
let val = config[key];
......
......@@ -38,7 +38,7 @@ export default {
filteredRunsList() {
let tagList = this.data.get('tagList') || [];
let runs = this.data.get('config.runs') || [];
let list = cloneDeep(tagList)
let list = cloneDeep(tagList);
return list.slice().map(item => {
item.tagList = item.tagList.filter(one => runs.includes(one.run));
return item;
......@@ -47,14 +47,12 @@ export default {
filteredPageList() {
let list = this.data.get('filteredRunsList');
let runs = this.data.get('config.runs') || [];
let currentPage = this.data.get('currentPage');
let pageSize = this.data.get('pageSize');
return list.slice((currentPage - 1) * pageSize, currentPage * pageSize);
},
total() {
let list = this.data.get('filteredRunsList') || [];
console.log(list);
return list.reduce((num, item) => {
let length = item.tagList.length;
return length ? num + 1 : num;
......@@ -64,7 +62,7 @@ export default {
let list = this.data.get('filteredRunsList') || [];
return list.reduce((num, item) => {
let length = item.tagList.length;
return length + num ;
return length + num;
}, 0);
}
},
......
......@@ -5,6 +5,7 @@ import './common/component/ui-common.styl';
import './home/index';
import './scalars/index';
import './images/index';
import './histogram/index';
import App from './App';
new App({
......
......@@ -10,6 +10,7 @@
</div>
<div class="visual-dl-scalar-right">
<ui-chart-page
expand="{{true}}"
config="{{filteredConfig}}"
runsItems="{{runsItems}}"
tagList="{{filteredTagsList}}"
......@@ -118,7 +119,7 @@ export default {
horizontal: 'step',
sortingMethod: 'default',
downloadLink: [],
outlier: [],
outlier: ['yes'],
runs: [],
running: true
}
......
......@@ -2,8 +2,16 @@ import {router} from 'san-router';
import Scalar from './Scalars';
router.add({
target: '#content',
rule: '/',
Component: Scalar
});
router.add({
target: '#content',
rule: '/scalars',
Component: Scalar
});
<template>
<div class="visual-dl-chart-page">
<ui-expand-panel info="{{tagList.length}}" title="{{title}}">
<ui-expand-panel isShow="{{expand}}" info="{{tagList.length}}" title="{{title}}">
<div class="visual-dl-chart-box">
<ui-chart
san-for="tag in filteredTagList"
......
......@@ -9,3 +9,8 @@ export const getPluginScalarsScalars = makeService('/data/plugin/scalars/scalars
export const getPluginImagesTags = makeService('/data/plugin/images/tags');
export const getPluginImagesImages = makeService('/data/plugin/images/images');
export const getPluginHistogramsTags = makeService('/data/plugin/histograms/tags');
export const getPluginHistogramsHistograms = makeService('/data/plugin/histograms/histograms');
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册