未验证 提交 5c4c3689 编写于 作者: B beatles-chameleon 提交者: GitHub

Merge pull request #182 from didi/0.4.x-dev-kevin

0.4.x dev kevin
#!/usr/bin/env node
// --inspect-brk
const program = require('commander');
const packageJson = require('../package.json');
......
......@@ -94,8 +94,8 @@ module.exports = (result) => {
}
}
}
// 分平台
if (result.json.platform) {
platforms.forEach((item) => {
......@@ -126,7 +126,7 @@ module.exports = (result) => {
let componentInfo = cmlUtils.lintHandleComponentUrl(currentWorkspace, result.json.file, filePath);
if (!componentInfo.filePath && componentInfo.refUrl) {
if (result.json.ast) {
if (componentInfo.refUrl.indexOf('plugin://') != 0 && result.json.ast) {
result.json.messages.push(findTokens(result.json.ast, [item, 'usingComponents', key], 'component: [' + filePath + '] is not found'));
}
}
......
const traverse = require('@babel/traverse')['default'];
const deepTraverse = require('traverse');
const uniq = require('lodash.uniq');
const config = require('../config');
const utils = require('../utils');
const DEFAULT_TOKENS_MAP = {
WEEX: ['weex', 'global'],
......@@ -62,6 +62,7 @@ const DEFAULT_TOKENS_MAP = {
]
};
// 这个path是否有需要校验的token
const needCheck = function (path, tokenList) {
let flag = false;
......@@ -103,37 +104,20 @@ function checkToken(path, token) {
}
const checkGlobal = function (ast, type = 'ALL') {
let tokensMap = DEFAULT_TOKENS_MAP;
const TOKENS_MAP = tokensMap;
type = type.toUpperCase();
const TOKENS_MAP = DEFAULT_TOKENS_MAP;
let tokenList = [];
let messages = [];
type = type.toUpperCase();
if (type === 'ALL') {
// 都要校验
tokenList = TOKENS_MAP[type];
}
else {
Object.keys(TOKENS_MAP).forEach(key => {
// 把自身的和All的去掉,其他端的token放进去
if (key !== type && key !== 'ALL') {
tokenList = tokenList.concat(TOKENS_MAP[key]);
}
})
// 然后要把自身的全局变量去掉
for (let i = 0;i < tokenList.length;) {
if (~TOKENS_MAP[type].indexOf(tokenList[i])) {
tokenList.splice(i, 1);
}
else {
i++;
}
Object.keys(TOKENS_MAP).forEach(key => {
if (type === 'ALL' || key !== type) {
tokenList = tokenList.concat(TOKENS_MAP[key]);
}
}
});
tokenList = uniq(tokenList);
const messages = [];
traverse(ast, {
tokenList.length && traverse(ast, {
enter: (path) => {
// path是一个上下文
// 需要校验的变量值
......@@ -154,8 +138,6 @@ const checkGlobal = function (ast, type = 'ALL') {
}
next = next.parent;
}
if (globalVariable && path.parent.type != 'ObjectMethod' && path.parent.type != 'ClassMethod') {
messages.push({
line: path.node.loc.start.line,
......@@ -178,13 +160,20 @@ const checkGlobal = function (ast, type = 'ALL') {
* @return {Object} 分析结果
*/
const getInterfaces = (ast) => {
let result = {};
let result = {
name: '',
properties: {}
};
ast.program.body.forEach(function (node) {
if (node.type == 'InterfaceDeclaration') {
let interfaceName = node.id.name;
result[interfaceName] = {};
result.name = interfaceName;
result.loc = {
line: node.id.loc.start.line,
column: node.id.loc.start.column
};
node.body.properties.map((property) => {
result[interfaceName][property.key.name] = {
result.properties[property.key.name] = {
type: property.value.type.replace(/TypeAnnotation/g, ''),
line: property.key.loc.start.line,
column: property.key.loc.start.column
......@@ -197,11 +186,15 @@ const getInterfaces = (ast) => {
/**
* 获取类定义
*
* @param {Object} ast ast
* @return {Object} 类定义
* @param {Object} ast ast
* @param {Object} isComp a flag indentify whether the ast is component or an interface portion
* @return {Object} 类定义
*/
const getClass = (ast) => {
const getClass = (ast, isComp) => {
return isComp ? getCompClassDef(ast) : getInterfacePortionClassDef(ast);
};
function getCompClassDef(ast) {
let classes = [];
traverse(ast, {
......@@ -221,15 +214,18 @@ const getClass = (ast) => {
});
}
// 参数
path.node.body.body.forEach(define => {
if (define.key.name == 'props') {
define.value.properties.forEach(property => {
clazz.properties.push(property.key.name);
});
}
else if (define.type == 'ClassMethod') {
clazz.methods.push(define.key.name);
else if (define.key.name == 'methods') {
define.value.properties.filter(property => {
return property.type === 'ObjectMethod';
}).forEach(property => {
clazz.methods.push(property.key.name);
});
}
else {
deepTraverse(define)
......@@ -268,7 +264,45 @@ const getClass = (ast) => {
});
return classes;
};
}
function getInterfacePortionClassDef(ast) {
let classes = [];
traverse(ast, {
enter(path) {
if (path.node.type == 'ClassDeclaration') {
let clazz = {
interfaces: [],
properties: [],
events: [],
methods: []
};
// 接口
if (path.node['implements']) {
path.node['implements'].forEach(implament => {
clazz.interfaces.push(implament.id.name);
});
}
// 参数
path.node.body.body.forEach(define => {
if (define.type == 'ClassProperty') {
clazz.properties.push(define.key.name);
}
else if (define.type == 'ClassMethod') {
clazz.methods.push(define.key.name);
}
});
classes.push(clazz);
}
}
});
return classes;
}
/**
* 校验接口与脚本
......@@ -277,76 +311,69 @@ const getClass = (ast) => {
* @return {Array} 数组
*/
const checkScript = async (result) => {
let script;
let platforms = config.getPlatforms();
['script'].concat(platforms).forEach(item => {
if (result[item] && result[item].ast) {
script = result[item];
}
});
if (!result['interface'] && script) {
let interfaceFile = script.file.replace(new RegExp('\\.(' + platforms.join('|') + ')\\.cml$', 'ig'), '.interface');
if (/\.interface$/.test(interfaceFile)) {
result['interface'] = {
messages: [{
msg: 'file: [' + interfaceFile + '] was not found!'
}],
file: interfaceFile
};
let validPlatforms = Object.keys(result)
.filter(platform => {
return platform && (!~['json', 'template', 'style', 'script'].indexOf(platform));
})
.filter(platform => {
return platform && (platform != 'interface');
});
// add a script type for multi-file components.
result['interface'] && validPlatforms.concat('script').forEach(platform => {
let script;
if (result[platform] && result[platform].ast) {
script = result[platform];
}
}
if (result['interface'] && result['interface'].ast && script && script.ast) {
const interfaceDefine = getInterfaces(result['interface'].ast);
const classDefines = getClass(script.ast);
classDefines.forEach(clazz => {
clazz.interfaces.forEach(interfaceName => {
let define = interfaceDefine[interfaceName];
for (let key of Object.keys(define)) {
if ((define[key] && define[key].type == 'Generic') && clazz.properties.indexOf(key) == -1) {
if (result['interface'] && result['interface'].ast && script && script.ast) {
const interfaceDefine = getInterfaces(result['interface'].ast);
const classDefines = getClass(script.ast, platform === 'script');
classDefines.forEach(clazz => {
clazz.interfaces.forEach(interfaceName => {
let define = interfaceDefine.name === interfaceName ? interfaceDefine.properties : null;
if (!define) {
result['interface'].messages.push({
line: define[key].line,
column: define[key].column,
token: key,
msg: 'property [' + key + '] is not found in file [' + script.file + ']'
msg: `The implement class name: "${interfaceName}" used in file: "${utils.toSrcPath(script.file)}" doesn\'t match the name defined in it\'s interface file: "${utils.toSrcPath(result['interface'].file)}"`
});
return;
}
else if ((define[key] && define[key].type == 'Function') && clazz.methods.indexOf(key) == -1) {
platforms.forEach(platform => {
if (result[platform]) {
result['interface'].messages.push({
line: define[key].line,
column: define[key].column,
token: key,
msg: 'method [' + key + '] is not found in file [' + script.file + ']'
});
}
});
for (let key of Object.keys(define)) {
if ((define[key] && define[key].type == 'Generic') && clazz.properties.indexOf(key) == -1) {
result['interface'].messages.push({
line: define[key].line,
column: define[key].column,
token: key,
msg: `interface property "${key}" is not defined in file "${utils.toSrcPath(script.file)}"`
});
}
else if ((define[key] && define[key].type == 'Function') && clazz.methods.indexOf(key) == -1) {
result['interface'].messages.push({
line: define[key].line,
column: define[key].column,
token: key,
msg: `interface method "${key}" is not defined in file "${utils.toSrcPath(script.file)}"`
});
}
}
}
clazz.events.forEach(event => {
if (!define[event.event] || (define[event.event] && (define[event.event].type != 'Function'))) {
script.messages.push({
line: event.line,
column: event.column,
token: event.event,
msg: 'event [' + event.event + '] is not defined in interface file [' + result['interface'].file + ']'
});
}
clazz.events.forEach(event => {
if (!define[event.event] || (define[event.event] && (define[event.event].type != 'Function'))) {
script.messages.push({
line: event.line,
column: event.column,
token: event.event,
msg: 'event "' + event.event + '" is not defined in interface file "' + utils.toSrcPath(result['interface'].file) + '"'
});
}
});
});
});
});
if (script.platform) {
let messages = checkGlobal(script.ast, script.platform);
script.messages = script.messages.concat(messages);
if (script.platform) {
let messages = checkGlobal(script.ast, script.platform);
script.messages = script.messages.concat(messages);
}
}
}
});
};
......
......@@ -16,6 +16,9 @@ function getUsingComponents(jsonAst, filePath = '') {
path: componentInfoPair[1]
};
})
.filter((infoPair) => {
return !(infoPair.path.indexOf('plugin://') === 0);
})
.forEach((infoPair) => {
let currentWorkspace = config.getCurrentWorkspace();
// filePath: is a full absolute path of the target template file
......
/**
* A class reprents a customized component.
* A class represents a customized component.
*/
class CustomizedNode {
constructor(tag, lang = 'cml') {
......
......@@ -3,7 +3,7 @@ const Tools = require('../../tools');
const mustacheRegex = /{{(.*?)}}/g;
module.exports = {
name: 'method-node',
name: 'mustache-node',
on: ['cml', 'vue'],
filter: {
key: 'rawValue',
......
......@@ -67,6 +67,14 @@ module.exports.parseSingleExpression = function(expressinoStr = '') {
nodes.push(getVarFromIdentifier(path.node.property, isFakeBlock));
}
},
LogicalExpression(path) {
if (path.get('left').isIdentifier()) {
nodes.push(getVarFromIdentifier(path.node.left, isFakeBlock));
}
if (path.get('right').isIdentifier()) {
nodes.push(getVarFromIdentifier(path.node.right, isFakeBlock));
}
},
BinaryExpression(path) {
if (path.get('left').isIdentifier()) {
nodes.push(getVarFromIdentifier(path.node.left, isFakeBlock));
......
/**
* A class represents an error message.
*/
class Message {
constructor({ line = undefined, column = undefined, token = '', msg = '' }) {
this.line = line;
this.column = column;
this.token = token;
this.msg = msg || 'An unknown error occurred'
}
}
module.exports = Message;
......@@ -33,7 +33,10 @@ module.exports = {
rules: {
'block-no-empty': null,
'selector-max-compound-selectors': 1,
'selector-type-no-unknown': true
'selector-type-no-unknown': [true, {
'ignore': ['custom-elements'],
'ignoreTypes': ['page']
}]
}
}
};
......@@ -76,7 +76,6 @@ const lintCmlFile = async (parts) => {
* @return {Promise} promise
*/
const checkFile = async (lintedResult, filepath) => {
// 校验style
checkers.style(lintedResult);
......@@ -108,7 +107,6 @@ const checkFile = async (lintedResult, filepath) => {
*/
const checkFileContent = async (filepath) => {
let parts = utils.getCmlParts(filepath);
let result = await lintCmlFile(parts);
result = await checkFile(result, filepath);
......@@ -128,7 +126,6 @@ const checkCMLFileSpecification = async (filepath) => {
messages: []
}
};
if (new RegExp('([^/]*?)\.(' + platforms.join('|') + ')\.cml$', 'g').test(filepath)) {
let interfaceFile = filepath.replace(new RegExp('\.(' + platforms.join('|') + ')\.cml$', 'g'), '.interface');
if (!fs.existsSync(interfaceFile)) {
......@@ -153,12 +150,11 @@ const checkCMLFileSpecification = async (filepath) => {
* @return {Promise} promise
*/
const checkInterfaceFileSpecification = async (filepath) => {
let parts = utils.getInterfaceParts(filepath);
let {parts, messages} = utils.getInterfaceParts(filepath);
let result = {};
let keys = Object.keys(parts);
if (keys.length > 1) {
if (messages.length == 0 && keys.length > 1) {
for (let key in parts) {
if (parts.hasOwnProperty(key)) {
let part = parts[key];
......@@ -170,13 +166,18 @@ const checkInterfaceFileSpecification = async (filepath) => {
result[key].platform = part.platformType;
}
}
// 校验脚本
checkers.script(result);
return result;
}
else {
return {};
return {
core: {
type: 'core',
messages,
file: filepath
}
};
}
}
......
......@@ -49,7 +49,6 @@ module.exports = async (currentWorkspace, needOutputWarnings = true) => {
console.log(chalk.red('[ERROR] ') + 'The current project is not a chameleon project!');
return;
}
let results = [];
if (config.getRuleOption('core-files-check')) {
......
......@@ -32,13 +32,14 @@ function getCustimizedTags(jsonAst, {platform = '', templatePath = ''}) {
Object.assign(componentsObj, baseJson.usingComponents, platformJson.usingComponents);
}
result = Object.entries(componentsObj).map((infoPair) => {
let [name, basePath] = infoPair;
return {
name: utils.toDash(name),
isOrigin: !utils.isCmlComponent(path.resolve(config.getCurrentWorkspace(), templatePath), basePath)
}
});
result = Object.entries(componentsObj)
.map((infoPair) => {
let [name, basePath] = infoPair;
return {
name: utils.toDash(name),
isOrigin: !utils.isCmlComponent(path.resolve(config.getCurrentWorkspace(), templatePath), basePath)
}
});
return result;
}
......
......@@ -7,12 +7,9 @@ const utils = require('../utils');
const fileSpec = require('../file-spec');
const chai = require('chai');
const assert = chai.assert;
const should = chai.should;
const expect = chai.expect;
const path = require('path');
describe('cml', function() {
......@@ -128,9 +125,9 @@ describe('cml', function() {
const result = await styleLinter(parts.style);
expect(result.messages).to.deep.equal([
{
"column": 3,
"line": 7,
"msg": "expected \"indent\", got \";\""
'column': 3,
'line': 7,
'msg': 'expected "indent", got ";"'
}
]);
});
......@@ -186,14 +183,14 @@ describe('cml', function() {
expect(result.messages).to.deep.equal(
[
{
"column": 5,
"line": 19,
"msg": "computed property \"hasApplyList\" cannot be used as an arrow function"
'column': 5,
'line': 19,
'msg': 'computed property "hasApplyList" cannot be used as an arrow function'
},
{
"column": 3,
"line": 30,
"msg": "lifecycle hook \"mounted\" cannot be used as an arrow function"
'column': 3,
'line': 30,
'msg': 'lifecycle hook "mounted" cannot be used as an arrow function'
}
]
);
......@@ -343,13 +340,13 @@ describe('cml', function() {
expect(result.script.messages).to.deep.equal([]);
});
it('no-interface', async function () {
const cmlPath = path.resolve(__dirname, './checker/cml/script/nointerface/nonstandard.wx.cml');
const parts = utils.getCmlParts(cmlPath);
const result = await fileSpec.lintCmlFile(parts);
checkers.script(result);
assert.equal(result['interface'].messages.length, 1);
});
// it('no-interface', async function () {
// const cmlPath = path.resolve(__dirname, './checker/cml/script/nointerface/nonstandard.wx.cml');
// const parts = utils.getCmlParts(cmlPath);
// const result = await fileSpec.lintCmlFile(parts);
// checkers.script(result);
// assert.equal(result['interface'].messages.length, 1);
// });
it('global-variable', async function () {
const cmlPath = path.resolve(__dirname, './checker/cml/script/global-variable/standard.wx.cml');
const parts = utils.getCmlParts(cmlPath);
......
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const groupBy = require('lodash.groupby');
const filter = require('lodash.filter');
const map = require('lodash.map');
const cliUtils = require('chameleon-tool-utils');
const config = require('./config');
const Message = require('./classes/message');
let isCmlComponent = (templatePath, usingPath) => {
let currentWorkspace = config.getCurrentWorkspace();
......@@ -13,6 +15,10 @@ let isCmlComponent = (templatePath, usingPath) => {
return !!interfaceInfo.filePath || (componentInfo && componentInfo.isCml);
}
let toSrcPath = (filePath = '') => {
return (filePath && path.isAbsolute(filePath)) ? path.relative(config.getCurrentWorkspace(), filePath) : filePath;
}
/**
* 转换成驼峰写法
*
......@@ -111,25 +117,108 @@ let getCmlParts = filepath => {
let getInterfaceParts = filepath => {
let content = fs.readFileSync(filepath, 'utf8');
let result = {};
let parts = cliUtils.splitParts({content});
let _result = {
parts: {},
messages: []
};
if (parts.script) {
parts.script.forEach(item => {
result[item.cmlType] = item;
_retrieveParts(filepath);
Object.assign(result[item.cmlType], {
params: {},
line: item.startLine,
file: filepath,
rawContent: item.tagContent,
platformType: item.cmlType
function _retrieveParts(interfaceFilePath) {
// terminate condition
if (!fs.existsSync(interfaceFilePath)) {
return;
}
const content = fs.readFileSync(interfaceFilePath, 'utf8');
const parts = cliUtils.splitParts({content});
// search parts.script array for interface defination and platform specific definations.
if (parts.script) {
parts.script.forEach(item => {
let errMsg = null;
let extraPartInfo = {
params: {},
line: item.startLine,
file: interfaceFilePath,
rawContent: item.tagContent,
platformType: item.cmlType
};
// for interface portion we should keep the origin filepath
if (item.cmlType === 'interface') {
extraPartInfo.file = filepath;
}
// check src references for platform definations
if (item.cmlType != 'interface' && item.attrs && item.attrs.src) {
const targetScriptPath = path.resolve(path.dirname(interfaceFilePath), item.attrs.src);
// the referenced source is a js file
if (/.js$/.test(item.attrs.src)) {
if (!fs.existsSync(targetScriptPath)) {
errMsg = new Message({
line: item.line,
column: item.tagContent.indexOf(item.attrs.src) + 1,
token: item.attrs.src,
msg: `The javascript file: "${toSrcPath(targetScriptPath)}" specified with attribute src was not found`
});
} else {
extraPartInfo.content = extraPartInfo.rawContent = extraPartInfo.tagContent = fs.readFileSync(targetScriptPath, 'utf8');
extraPartInfo.file = targetScriptPath;
}
}
// the referenced source is a cml file
if (/.cml$/.test(item.attrs.src)) {
if (!fs.existsSync(targetScriptPath)) {
errMsg = new Message({
line: item.line,
column: item.tagContent.indexOf(item.attrs.src) + 1,
token: item.attrs.src,
msg: `The cml file: "${toSrcPath(targetScriptPath)}" specified with attribute src was not found`
});
} else {
const cmlFileContent = fs.readFileSync(targetScriptPath, 'utf8');
const cmlParts = cliUtils.splitParts({content: cmlFileContent});
const scriptPart = cmlParts.script ? cmlParts.script.filter(part => {
return part.type === 'script';
}) : null;
if (scriptPart && scriptPart.length) {
extraPartInfo.content = extraPartInfo.rawContent = extraPartInfo.tagContent = scriptPart[0].content;
extraPartInfo.file = targetScriptPath;
} else {
errMsg = new Message({
line: item.line,
column: item.tagContent.indexOf(item.attrs.src) + 1,
token: item.attrs.src,
msg: `The referenced file: "${toSrcPath(targetScriptPath)}" may not has a script portion`
});
}
}
}
}
// previous cmlType defination has a higher priority.
if (!errMsg && !_result.parts[item.cmlType]) {
_result.parts[item.cmlType] = {...item, ...extraPartInfo};
}
if (errMsg) {
_result.messages.push(errMsg);
}
});
});
}
// search parts.customBlocks array for include defination which may contains another interface file.
let include = null;
if (parts.customBlocks) {
parts.customBlocks.forEach(item => {
if (item.type === 'include') {
include = item;
}
});
}
if (include && include.attrs && include.attrs.src) {
let newFilePath = path.resolve(path.dirname(interfaceFilePath), include.attrs.src);
return _retrieveParts(newFilePath);
}
return;
}
return result;
return _result;
}
......@@ -148,7 +237,6 @@ let outputWarnings = (result) => {
flag = true;
return true;
});
result = groupBy(result, 'file');
for (let key of Object.keys(result)) {
if (key !== 'undefined') {
......@@ -165,14 +253,17 @@ let outputWarnings = (result) => {
item.messages
.sort((preMsg, nextMsg) => {
return preMsg.line - nextMsg.line;
if (preMsg.line == undefined || preMsg.column == undefined || nextMsg.line == undefined || nextMsg.column == undefined) {
return 0;
}
return (preMsg.line - nextMsg.line) * 10000 + (preMsg.column - nextMsg.column);
})
.forEach((message) => {
if (message.line !== undefined && item.start !== undefined && message.column !== undefined) {
console.log('[' + chalk.cyan(message.line + item.start - 1) + ' (line), ' + chalk.cyan(message.column) + ' (column)]' + ' ' + message.msg);
}
else {
console.log(message);
console.log(message.msg);
}
});
});
......@@ -187,5 +278,6 @@ module.exports = {
getInterfaceParts,
outputWarnings,
toDash,
isCmlComponent
isCmlComponent,
toSrcPath
};
const fs = require('fs');
const path = require('path');
const cliUtils = require('chameleon-tool-utils');
const partRegExp = /<\s*(script)\s*([^>]*?)\s*>([\s\S]*?)<\s*\/\s*\1\s*>/g;
const paramRegExp = /([^\s\=]+)=?(['"])?([^\s\=\'\"]*)\2/g;
function getContent(filePath = null) {
let fileRawContent = ''; let interfaceContent = '';
function _retrieveInterfaceContent(filePath = null) {
let fileContent = '';
let splitParts = {};
let include = null;
try {
filePath && (fileRawContent = fs.readFileSync(filePath, 'utf8'));
fileContent = fs.readFileSync(filePath, 'utf8');
} catch (err) {
// console.warn("cml-interface-parser:", err.message);
}
if (fileContent) {
splitParts = cliUtils.splitParts({ content: fileContent });
}
if (splitParts.customBlocks && splitParts.customBlocks.length) {
splitParts.customBlocks.forEach(part => {
if (part && (part.type === 'include')) {
include = part;
}
});
}
if (include && include.attrs && include.attrs.src) {
return _retrieveInterfaceContent(path.resolve(path.dirname(filePath), include.attrs.src));
}
return fileContent;
}
function getContent(filePath = null) {
let fileRawContent = ''; let interfaceContent = '';
fileRawContent = _retrieveInterfaceContent(filePath);
fileRawContent.replace(partRegExp, (match, type, rawAttribute, definationContent) => {
!interfaceContent && rawAttribute.replace(paramRegExp, (attrMatch, attrName, mark, attrValue) => {
......
......@@ -4,7 +4,7 @@ const expect = chai.expect;
const Parser = require('../index');
describe('parser check', function() {
it('should pass check', function() {
it('normal interface file: should pass check', function() {
let parser = new Parser({filePath: path.resolve(__dirname, './docs/index.interface')});
let results = parser.getParseResults();
expect(results).to.have.deep.property('vars', ['cstyle', 'bottomOffset', 'scrollDirection']);
......@@ -17,4 +17,17 @@ describe('parser check', function() {
name: 'scrollDirection', valueType: 'String', props: [], typeChain: []
}]);
});
it('include interface file: should pass check', function() {
let parser = new Parser({filePath: path.resolve(__dirname, './docs/include-interface.interface')});
let results = parser.getParseResults();
expect(results).to.have.deep.property('vars', ['cstyle', 'bottomOffset', 'scrollDirection']);
expect(results).to.have.deep.property('methods', ['customscroll', 'scrolltobottom']);
expect(results).to.have.deep.property('props', [{
name: 'cstyle', valueType: 'String', props: [], typeChain: []
}, {
name: 'bottomOffset', valueType: 'Number', props: [], typeChain: []
}, {
name: 'scrollDirection', valueType: 'String', props: [], typeChain: []
}]);
});
});
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册