Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
半栈学徒
incubator-echarts
提交
05a0c7d1
I
incubator-echarts
项目概览
半栈学徒
/
incubator-echarts
与 Fork 源项目一致
从无法访问的项目Fork
通知
5
Star
0
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
I
incubator-echarts
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
前往新版Gitcode,体验更适合开发者的 AI 搜索 >>
提交
05a0c7d1
编写于
5月 07, 2021
作者:
P
pissang
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
test(visual): fully control the replay timeline.
上级
210f650d
变更
7
隐藏空白更改
内联
并排
Showing
7 changed file
with
312 addition
and
154 deletion
+312
-154
test/lib/simpleRequire.js
test/lib/simpleRequire.js
+7
-2
test/runTest/cli.js
test/runTest/cli.js
+76
-58
test/runTest/client/client.js
test/runTest/client/client.js
+1
-1
test/runTest/runtime/ActionPlayback.js
test/runTest/runtime/ActionPlayback.js
+39
-44
test/runTest/runtime/MockDate.js
test/runTest/runtime/MockDate.js
+0
-42
test/runTest/runtime/main.js
test/runTest/runtime/main.js
+27
-7
test/runTest/runtime/timeline.js
test/runTest/runtime/timeline.js
+162
-0
未找到文件。
test/lib/simpleRequire.js
浏览文件 @
05a0c7d1
...
...
@@ -26,7 +26,6 @@
// Limitations:
// 1. Not support ancient browsers.
// 2. Only `paths` can be configured
(
function
(
global
)
{
var
requireCfg
=
{
paths
:
{}
}
...
...
@@ -220,6 +219,12 @@
// Clear before flush. Avoid more require in the callback.
pendingRequireCallbacks
=
[];
pendingRequireCallbackParams
=
[];
// Start visual regression test before callback
if
(
typeof
__VST_START__
!==
'
undefined
'
)
{
__VST_START__
();
}
for
(
var
i
=
0
;
i
<
requireCallbackToFlush
.
length
;
i
++
)
{
requireCallbackToFlush
[
i
]
&&
requireCallbackToFlush
[
i
].
apply
(
null
,
requireCallbackParamsToFlush
[
i
]);
}
...
...
@@ -234,4 +239,4 @@
global
.
require
=
require
;
global
.
define
=
define
;
global
.
define
.
amd
=
{};
})(
window
);
\ No newline at end of file
})(
window
);
test/runTest/cli.js
浏览文件 @
05a0c7d1
...
...
@@ -24,9 +24,8 @@ const fs = require('fs');
const
path
=
require
(
'
path
'
);
const
program
=
require
(
'
commander
'
);
const
compareScreenshot
=
require
(
'
./compareScreenshot
'
);
const
{
testNameFromFile
,
fileNameFromTest
,
getVersionDir
,
buildRuntimeCode
,
waitTime
,
getEChartsTestFileNa
me
}
=
require
(
'
./util
'
);
const
{
testNameFromFile
,
fileNameFromTest
,
getVersionDir
,
buildRuntimeCode
,
getEChartsTestFileName
,
waitTi
me
}
=
require
(
'
./util
'
);
const
{
origin
}
=
require
(
'
./config
'
);
const
Timeline
=
require
(
'
./Timeline
'
);
const
cwebpBin
=
require
(
'
cwebp-bin
'
);
const
{
execFile
}
=
require
(
'
child_process
'
);
...
...
@@ -124,7 +123,6 @@ async function takeScreenshot(page, fullPage, fileUrl, desc, isExpected, minor)
}
async
function
runActions
(
page
,
testOpt
,
isExpected
,
screenshots
)
{
let
timeline
=
new
Timeline
(
page
);
let
actions
;
try
{
let
actContent
=
fs
.
readFileSync
(
path
.
join
(
__dirname
,
'
actions
'
,
testOpt
.
name
+
'
.json
'
));
...
...
@@ -135,43 +133,9 @@ async function runActions(page, testOpt, isExpected, screenshots) {
return
;
}
let
playbackSpeed
=
+
program
.
speed
;
for
(
let
action
of
actions
)
{
await
page
.
evaluate
((
x
,
y
)
=>
{
window
.
scrollTo
(
x
,
y
);
},
action
.
scrollX
,
action
.
scrollY
);
let
count
=
0
;
async
function
_innerTakeScreenshot
()
{
if
(
!
program
.
save
)
{
return
;
}
const
desc
=
action
.
desc
||
action
.
name
;
const
{
screenshotName
,
screenshotPath
,
rawScreenshotPath
}
=
await
takeScreenshot
(
page
,
false
,
testOpt
.
fileUrl
,
desc
,
isExpected
,
count
++
);
screenshots
.
push
({
screenshotName
,
desc
,
screenshotPath
,
rawScreenshotPath
});
}
await
timeline
.
runAction
(
action
,
_innerTakeScreenshot
,
playbackSpeed
);
if
(
count
===
0
)
{
await
waitTime
(
200
);
await
_innerTakeScreenshot
();
}
// const desc = action.desc || action.name;
// const {screenshotName, screenshotPath} = await takeScreenshot(page, false, testOpt.fileUrl, desc, version);
// screenshots.push({screenshotName, desc, screenshotPath});
}
timeline
.
stop
();
await
page
.
evaluate
(
async
(
actions
)
=>
{
await
__VST_RUN_ACTIONS__
(
actions
);
},
actions
);
}
async
function
runTestPage
(
browser
,
testOpt
,
version
,
runtimeCode
,
isExpected
)
{
...
...
@@ -184,6 +148,65 @@ async function runTestPage(browser, testOpt, version, runtimeCode, isExpected) {
page
.
setRequestInterception
(
true
);
page
.
on
(
'
request
'
,
request
=>
replaceEChartsVersion
(
request
,
version
));
async
function
pageScreenshot
()
{
if
(
!
program
.
save
)
{
return
;
}
// Final shot.
await
page
.
mouse
.
move
(
0
,
0
);
const
desc
=
'
Full Shot
'
;
const
{
screenshotName
,
screenshotPath
,
rawScreenshotPath
}
=
await
takeScreenshot
(
page
,
true
,
fileUrl
,
desc
,
isExpected
);
screenshots
.
push
({
screenshotName
,
desc
,
screenshotPath
,
rawScreenshotPath
});
}
await
page
.
exposeFunction
(
'
__VST_MOUSE_MOVE__
'
,
async
(
x
,
y
)
=>
{
await
page
.
mouse
.
move
(
x
,
y
);
});
await
page
.
exposeFunction
(
'
__VST_MOUSE_DOWN__
'
,
async
()
=>
{
await
page
.
mouse
.
down
();
});
await
page
.
exposeFunction
(
'
__VST_MOUSE_UP__
'
,
async
()
=>
{
await
page
.
mouse
.
up
();
});
let
waitClientScreenshot
=
new
Promise
((
resolve
)
=>
{
// TODO wait for this function exposed?
page
.
exposeFunction
(
'
__VST_FULL_SCREENSHOT__
'
,
()
=>
{
pageScreenshot
().
then
(
resolve
);
});
});
let
actionScreenshotCount
=
{};
await
page
.
exposeFunction
(
'
__VST_ACTION_SCREENSHOT__
'
,
async
(
action
)
=>
{
if
(
!
program
.
save
)
{
return
;
}
const
desc
=
action
.
desc
||
action
.
name
;
actionScreenshotCount
[
action
.
name
]
=
actionScreenshotCount
[
action
.
name
]
||
0
;
const
{
screenshotName
,
screenshotPath
,
rawScreenshotPath
}
=
await
takeScreenshot
(
page
,
false
,
testOpt
.
fileUrl
,
desc
,
isExpected
,
actionScreenshotCount
[
action
.
name
]
++
);
screenshots
.
push
({
screenshotName
,
desc
,
screenshotPath
,
rawScreenshotPath
});
});
await
page
.
evaluateOnNewDocument
(
runtimeCode
);
page
.
on
(
'
console
'
,
msg
=>
{
...
...
@@ -203,23 +226,18 @@ async function runTestPage(browser, testOpt, version, runtimeCode, isExpected) {
timeout
:
10000
});
await
waitTime
(
500
);
// Wait for animation or something else. Pending
// Final shot.
await
page
.
mouse
.
move
(
0
,
0
);
if
(
program
.
save
)
{
let
desc
=
'
Full Shot
'
;
const
{
screenshotName
,
screenshotPath
,
rawScreenshotPath
}
=
await
takeScreenshot
(
page
,
true
,
fileUrl
,
desc
,
isExpected
);
screenshots
.
push
({
screenshotName
,
desc
,
screenshotPath
,
rawScreenshotPath
});
}
let
autoscreenshotTimeout
;
await
Promise
.
race
([
waitClientScreenshot
,
new
Promise
(
resolve
=>
{
autoscreenshotTimeout
=
setTimeout
(()
=>
{
console
.
log
(
`Automatically screenshot in
${
testNameFromFile
(
fileUrl
)}
`
);
pageScreenshot
().
then
(
resolve
)
},
1000
)
})
]);
clearTimeout
(
autoscreenshotTimeout
);
await
runActions
(
page
,
testOpt
,
isExpected
,
screenshots
);
}
...
...
@@ -325,7 +343,7 @@ async function runTests(pendingTests) {
// TODO Not hardcoded.
// let runtimeCode = fs.readFileSync(path.join(__dirname, 'tmp/testRuntime.js'), 'utf-8');
let
runtimeCode
=
await
buildRuntimeCode
();
runtimeCode
=
`window.__
TE
ST_PLAYBACK_SPEED__ =
${
program
.
speed
||
1
}
;\n
${
runtimeCode
}
`
;
runtimeCode
=
`window.__
V
ST_PLAYBACK_SPEED__ =
${
program
.
speed
||
1
}
;\n
${
runtimeCode
}
`
;
process
.
on
(
'
exit
'
,
()
=>
{
browser
.
close
();
...
...
test/runTest/client/client.js
浏览文件 @
05a0c7d1
...
...
@@ -182,7 +182,7 @@ const app = new Vue({
finishedCount
++
;
}
});
return
+
(
finishedCount
/
this
.
fullTests
.
length
*
100
).
toFixed
(
0
)
||
0
;
return
+
(
finishedCount
/
this
.
fullTests
.
length
*
100
).
toFixed
(
1
)
||
0
;
},
tests
()
{
...
...
test/runTest/
Timeline
.js
→
test/runTest/
runtime/ActionPlayback
.js
浏览文件 @
05a0c7d1
...
...
@@ -17,21 +17,25 @@
* under the License.
*/
const
{
waitTime
}
=
require
(
'
./util
'
)
;
import
*
as
timeline
from
'
./timeline
'
;
module
.
exports
=
class
Timeline
{
function
waitTime
(
time
)
{
return
new
Promise
(
resolve
=>
{
setTimeout
(()
=>
{
resolve
();
},
time
);
});
};
constructor
(
page
)
{
this
.
_page
=
page
;
export
class
ActionPlayback
{
constructor
()
{
this
.
_timer
=
0
;
this
.
_current
=
0
;
this
.
_ops
=
[];
this
.
_currentOpIndex
=
0
;
this
.
_client
;
this
.
_isLastOpMousewheel
=
false
;
}
...
...
@@ -43,11 +47,7 @@ module.exports = class Timeline {
}
async
runAction
(
action
,
takeScreenshot
,
playbackSpeed
)
{
if
(
!
this
.
_client
)
{
this
.
_client
=
await
this
.
_page
.
target
().
createCDPSession
();
}
async
runAction
(
action
,
playbackSpeed
)
{
this
.
stop
();
playbackSpeed
=
playbackSpeed
||
1
;
...
...
@@ -75,13 +75,21 @@ module.exports = class Timeline {
self
.
_elapsedTime
+=
dTime
*
playbackSpeed
;
self
.
_current
=
current
;
await
self
.
_update
(
takeScreenshot
,
playbackSpeed
);
await
self
.
_update
(
async
()
=>
{
// Pause timeline when doing screenshot to avoid screenshot needs taking a while.
timeline
.
pause
();
await
__VST_ACTION_SCREENSHOT__
(
action
);
timeline
.
resume
();
},
playbackSpeed
);
if
(
self
.
_currentOpIndex
>=
self
.
_ops
.
length
)
{
// Finished
resolve
();
}
else
{
self
.
_timer
=
setTimeout
(
tick
,
16
);
self
.
_timer
=
setTimeout
(
tick
,
0
);
}
}
tick
();
...
...
@@ -96,7 +104,7 @@ module.exports = class Timeline {
}
}
async
_update
(
takeScreenshot
,
playbackSpeed
)
{
async
_update
(
playbackSpeed
)
{
let
op
=
this
.
_ops
[
this
.
_currentOpIndex
];
if
(
op
.
time
>
this
.
_elapsedTime
)
{
...
...
@@ -108,50 +116,37 @@ module.exports = class Timeline {
let
takenScreenshot
=
false
;
switch
(
op
.
type
)
{
case
'
mousedown
'
:
await
page
.
mouse
.
move
(
op
.
x
,
op
.
y
);
await
page
.
mouse
.
down
();
await
__VST_MOUSE_MOVE__
(
op
.
x
,
op
.
y
);
await
__VST_MOUSE_DOWN__
();
break
;
case
'
mouseup
'
:
await
page
.
mouse
.
move
(
op
.
x
,
op
.
y
);
await
__VST_MOUSE_MOVE__
(
op
.
x
,
op
.
y
);
await
page
.
mouse
.
up
();
break
;
case
'
mousemove
'
:
await
page
.
mouse
.
move
(
op
.
x
,
op
.
y
);
await
__VST_MOUSE_MOVE__
(
op
.
x
,
op
.
y
);
break
;
case
'
mousewheel
'
:
await
page
.
evaluate
((
x
,
y
,
deltaX
,
deltaY
)
=>
{
let
element
=
document
.
elementFromPoint
(
x
,
y
);
// Here dispatch mousewheel event because echarts used it.
// TODO Consider upgrade?
let
event
=
new
WheelEvent
(
'
mousewheel
'
,
{
// PENDING
// Needs inverse delta?
deltaY
,
clientX
:
x
,
clientY
:
y
,
// Needs bubble to parent container
bubbles
:
true
});
element
.
dispatchEvent
(
event
);
},
op
.
x
,
op
.
y
,
op
.
deltaX
||
0
,
op
.
deltaY
);
let
element
=
document
.
elementFromPoint
(
op
.
x
,
op
.
y
);
// Here dispatch mousewheel event because echarts used it.
// TODO Consider upgrade?
let
event
=
new
WheelEvent
(
'
mousewheel
'
,
{
// PENDING
// Needs inverse delta?
deltaY
,
clientX
:
x
,
clientY
:
y
,
// Needs bubble to parent container
bubbles
:
true
});
element
.
dispatchEvent
(
event
);
this
.
_isLastOpMousewheel
=
true
;
// console.log('mousewheel', op.x, op.y, op.deltaX, op.deltaY);
// await this._client.send('Input.dispatchMouseEvent', {
// type: 'mouseWheel',
// x: op.x,
// y: op.y,
// deltaX: op.deltaX,
// deltaY: op.deltaY
// });
break
;
case
'
screenshot
'
:
await
takeScreenshot
();
takenScreenshot
=
true
;
break
;
case
'
valuechange
'
:
if
(
op
.
target
===
'
select
'
)
{
await
page
.
select
(
op
.
selector
,
op
.
value
);
}
document
.
querySelector
(
op
.
selector
).
value
=
op
.
value
;
break
;
}
...
...
test/runTest/runtime/MockDate.js
已删除
100644 → 0
浏览文件 @
210f650d
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Mock date.
const
NativeDate
=
window
.
Date
;
const
fixedTimestamp
=
1566458693300
;
const
actualTimestamp
=
NativeDate
.
now
();
const
mockNow
=
function
()
{
// speed up
return
fixedTimestamp
+
(
NativeDate
.
now
()
-
actualTimestamp
)
*
window
.
__TEST_PLAYBACK_SPEED__
;
};
function
MockDate
(...
args
)
{
if
(
!
args
.
length
)
{
return
new
NativeDate
(
mockNow
());
}
else
{
return
new
NativeDate
(...
args
);
}
}
MockDate
.
prototype
=
Object
.
create
(
NativeDate
.
prototype
);
Object
.
setPrototypeOf
(
MockDate
,
NativeDate
);
MockDate
.
now
=
mockNow
;
export
default
MockDate
;
test/runTest/runtime/main.js
浏览文件 @
05a0c7d1
...
...
@@ -18,13 +18,8 @@
*/
import
seedrandom
from
'
seedrandom
'
;
import
MockDate
from
'
./MockDate
'
;
window
.
Date
=
MockDate
;
if
(
typeof
__TEST_PLAYBACK_SPEED__
===
'
undefined
'
)
{
window
.
__TEST_PLAYBACK_SPEED__
=
1
;
}
import
{
ActionPlayback
}
from
'
./ActionPlayback
'
;
import
*
as
timeline
from
'
./timeline
'
;
let
myRandom
=
new
seedrandom
(
'
echarts-random
'
);
// Random for echarts code.
...
...
@@ -42,6 +37,31 @@ window.__random__inner__ = function () {
return
val
;
};
let
vstStarted
=
false
;
window
.
__VST_START__
=
function
()
{
if
(
vstStarted
)
{
return
;
}
vstStarted
=
true
;
timeline
.
start
();
// Screenshot after 500ms
setTimeout
(
function
()
{
// Pause timeline until run actions.
timeline
.
pause
();
__VST_FULL_SCREENSHOT__
();
},
500
);
}
window
.
__VST_RUN_ACTIONS__
=
async
function
(
actions
)
{
timeline
.
resume
();
const
actionPlayback
=
new
ActionPlayback
();
for
(
let
action
of
actions
)
{
await
actionPlayback
.
runAction
(
action
);
}
actionPlayback
.
stop
();
}
window
.
addEventListener
(
'
DOMContentLoaded
'
,
()
=>
{
let
style
=
document
.
createElement
(
'
style
'
);
// Disable all css animation since it will cause screenshot inconsistent.
...
...
test/runTest/runtime/timeline.js
0 → 100644
浏览文件 @
05a0c7d1
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
if
(
typeof
__VST_PLAYBACK_SPEED__
===
'
undefined
'
)
{
window
.
__VST_PLAYBACK_SPEED__
=
1
;
}
const
nativeRaf
=
window
.
requestAnimationFrame
;
const
FIXED_FRAME_TIME
=
16
;
const
MAX_FRAME_TIME
=
80
;
const
TIMELINE_START
=
1566458693300
;
window
.
__VST_TIMELINE_PAUSED__
=
true
;
let
realFrameStartTime
=
0
;
/** Control timeline loop */
let
rafCbs
=
[];
let
frameIdx
=
0
;
let
timelineTime
=
0
;
function
timelineLoop
()
{
if
(
!
__VST_TIMELINE_PAUSED__
)
{
realFrameStartTime
=
NativeDate
.
now
();
frameIdx
++
;
timelineTime
+=
FIXED_FRAME_TIME
;
const
currentRafCbs
=
rafCbs
;
// Clear before calling the callbacks. raf may be registered in the callback
rafCbs
=
[];
currentRafCbs
.
forEach
((
cb
)
=>
{
cb
();
});
flushTimeoutHandlers
();
flushIntervalHandlers
();
}
nativeRaf
(
timelineLoop
);
}
nativeRaf
(
timelineLoop
);
window
.
requestAnimationFrame
=
function
(
cb
)
{
rafCbs
.
push
(
cb
);
};
/** Mock setTimeout, setInterval */
let
timeoutHandlers
=
[];
let
intervalHandlers
=
[];
let
timeoutId
=
1
;
let
intervalId
=
1
;
window
.
setTimeout
=
function
(
cb
,
timeout
)
{
const
elapsedFrame
=
Math
.
ceil
(
Math
.
max
(
timeout
||
0
,
1
)
/
FIXED_FRAME_TIME
);
timeoutHandlers
.
push
({
callback
:
cb
,
id
:
timeoutId
,
frame
:
frameIdx
+
elapsedFrame
});
return
timeoutId
++
;
}
window
.
clearTimeout
=
function
(
id
)
{
const
idx
=
timeoutHandlers
.
findIndex
(
handler
=>
{
handler
.
id
===
id
});
if
(
idx
>=
0
)
{
timeoutHandlers
.
splice
(
idx
,
1
);
}
}
function
flushTimeoutHandlers
()
{
let
newTimeoutHandlers
=
[];
for
(
let
i
=
0
;
i
<
timeoutHandlers
.
length
;
i
++
)
{
const
handler
=
timeoutHandlers
[
i
];
if
(
handler
.
frame
===
frameIdx
)
{
handler
.
callback
();
}
else
{
newTimeoutHandlers
.
push
(
handler
);
}
}
timeoutHandlers
=
newTimeoutHandlers
;
}
window
.
setInterval
=
function
(
cb
,
interval
)
{
const
intervalFrame
=
Math
.
ceil
(
Math
.
max
(
interval
||
0
,
1
)
/
FIXED_FRAME_TIME
);
intervalHandlers
.
push
({
callback
:
cb
,
id
:
intervalId
,
intervalFrame
,
frame
:
frameIdx
+
intervalFrame
})
return
intervalId
++
;
}
window
.
clearInterval
=
function
()
{
const
idx
=
intervalHandlers
.
findIndex
(
handler
=>
{
handler
.
id
===
id
});
if
(
idx
>=
0
)
{
intervalHandlers
.
splice
(
idx
,
1
);
}
}
function
flushIntervalHandlers
()
{
for
(
let
i
=
0
;
i
<
intervalHandlers
.
length
;
i
++
)
{
const
handler
=
intervalHandlers
[
i
];
if
(
handler
.
frame
===
frameIdx
)
{
handler
.
callback
();
handler
.
frame
+=
handler
.
intervalFrame
;
}
}
}
/** Mock Date */
const
NativeDate
=
window
.
Date
;
const
mockNow
=
function
()
{
// speed up
return
TIMELINE_START
+
timelineTime
*
window
.
__VST_PLAYBACK_SPEED__
;
};
function
MockDate
(...
args
)
{
if
(
!
args
.
length
)
{
return
new
NativeDate
(
mockNow
());
}
else
{
return
new
NativeDate
(...
args
);
}
}
MockDate
.
prototype
=
Object
.
create
(
NativeDate
.
prototype
);
Object
.
setPrototypeOf
(
MockDate
,
NativeDate
);
MockDate
.
now
=
mockNow
;
window
.
Date
=
MockDate
;
export
function
start
()
{
window
.
__VST_TIMELINE_PAUSED__
=
false
;
}
export
function
pause
()
{
window
.
__VST_TIMELINE_PAUSED__
=
true
;
}
export
function
resume
()
{
window
.
__VST_TIMELINE_PAUSED__
=
false
;
}
\ No newline at end of file
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录