陈建华的博客
专注web开发
【chrome扩展】文件系统(第七章)
2014-11-30 10:33:33   阅读4587次

读写本地文件是一个程序最基本的功能,而对于Web技术来说,出于安全因素考虑,浏览器一直没有完全将这一功能开放给JavaScript,直到HTML5提出了FileSystem API。

Chrome为应用提供了权限更加开放,功能更加强大的一系列文件系统接口,以满足Chrome应用作为桌面程序对磁盘读写的需求。在本章将详细为大家讲解选择目录、读取文件和写文件的方法。

要使用FileSystem API需要在Manifest中声明fileSystem权限:

permissions: {
    "fileSystem"
}

但如果只声明了上述权限,并不能写入文件及获取目录。如果还需要写入文件和获取目录需要进行如下声明:

permissions: {
    {"fileSystem": ["write", "directory"]}
}

值得注意的是,上面的权限声明中请求的权限值为对象型,即{"fileSystem": ["write", "directory"]},而多数情况下是字符串型,如"storage"。


7.1目录及文件操作对象

在C语言中操作文件时,实际操作的是文件指针,在Chrome应用中是通过目录及文件操作对象进行的。我们称目录操作对象为DirectoryEntry,文件操作对象为FileEntry,两者均继承自Entry对象。

Entry有五个属性,分别是filesystem、fullPath、isDirectory、isFile和name。其中filesystem是当前Entry所在的文件系统,filesystem还有两个属性,分别是name和root,name是此文件系统的名称,root是此文件系统的根目录Entry。fullPath是当前目录的绝对地址,字符串型。isDirectory和isFile分别用于标识当前操作对象的类型,对于DirectoryEntry来说,两者的值分别为true和false。name是当前目录的名称,字符串型。

另外DirectoryEntry还有四种方法,分别是createReader、getDirectory、getFile和removeRecursively。createReader用于创建新的DirectoryReader对象来读取当前目录中的子目录和文件。getDirectory用于读取或创建当前目录下的子目录。getFile用于读取或创建当前目录下的文件。removeRecursively用于删除当前目录下的所有文件和子目录,以及当前目录本身。

01ficOvebRpy.png

DirectoryEntry的结构

FileEntry与DirectoryEntry有很多类似的地方,如FileEntry具有和DirectoryEntry一样的五个属性,只不过对于FileEntry来说isDirectory和isFile的值分别为false和true。

但是FileEntry所具有的方法与DirectoryEntry不同,FileEntry只有两种方法,分别是createWriter和file。其中createWriter用于创建一个新的FileWriter对象以用来向当前文件写入数据。file方法会返回File对象,继承自Blob对象(包含文件内容、大小、MIME类型),包含文件名和最后修改时间。

01ficPB6pHTC.png

FileEntry的结构

Chrome应用中的fileSystem接口是对HTML5已有的文件系统接口的扩充,它允许Chrome应用读写硬盘中用户选择的任意位置,而HTML5本身提供的文件系统接口则只能在沙箱中读写文件,并不能获取用户磁盘中真正的目录。

有关HTML5文件系统更加详细的说明可以参见W3C文档http://www.w3.org/TR/file-system-api/

7.2获取目录及文件操作对象

无论是操作文件还是操作目录,都是对相应的操作对象进行操作,所以第一步都需要获取到目录及文件操作对象。Chrome应用无法像C语言那样通过路径直接操作文件,目录及文件操作对象总是需要通过Chrome自带的文件选择窗口获取的。

通过chooseEntry方法可以获取到目录及文件操作对象。当chooseEntry被执行时,一个文件选择窗口会马上弹出,所以应该让一些事件来触发其运行,比如点击按钮等,否则可能会让用户感到困惑。

document.getElementById('openfile').onclick = function(){
    chrome.fileSystem.chooseEntry({}, function(fileEntry){
        console.log(fileEntry);
        //do something with fileEntry
    });
}

上面这段代码会让用户选择一个已存在的文件,并返回此文件对应的操作对象。如果在Manifest中声明了写权限,fileEntry是可写的,否则是只读的。

在调用chooseEntry方法时,我们在上例中传递了一个空对象,这个对象用来定义chooseEntry打开的参数,默认情况下会以打开文件的方式获取操作对象。这个定义打开参数的对象完整结构如下:

{
    type: 打开类型,包括openFile、openWritableFile、saveFile和openDirectory,
    suggestedName: 建议的文件名,会自动显示在保存窗口的文件名输入框中,
    accepts: [
        {
            description: 此选项的文字描述,
            mimeTypes: [接受的mime类型,如"image/jpeg"或"audio/*"],
            extensions: [接受的文件后缀,如"jpg"或"gif"]
        }
    ],
    acceptsAllTypes: 如果设定了接受的指定类型文件,是否接受所有的类型文件,
    acceptsMultiple: 是否接受多个文件,只支持openFile和openWritableFile的打开方式
}

在参数对象中如果未指定type属性,则默认为openFile。由于在声明write权限后openFile方法获取的FileEntry可写,所以请考虑避免使用openWritableFile,因为在以后openWritableFile很可能被openFile替代。

但是saveFile却无法被openFile替代,因为saveFile可以创建新的文件,openFile则不可以。

将type指定为openDirectory则可以获取到目录操作对象:

document.getElementById('opendirectory').onclick = function(){
    chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry){
        console.log(Entry);
        //do something with Entry
    });
}

7.3读取文件

在7.1节中提到过FileEntry的file方法可以获取到文件的相关信息,实际上file方法返回的是HTML5中的File类型对象,所以有必要先介绍一下HTML5中的FILE对象。

HTML5可以在文件未上传之前在浏览器端获取到文件的相关信息,就是通过File API。当用户通过文件选择控件选择文件后,JavaScript就可以通过控件DOM的files属性获取到对应的File对象:

document.getElementById('myFile').onchange = function(){
    var file = this.files[0];
    console.log(file);
}

对应的HTML为:

<input type="file" id="myFile" />

获取到的FILE对象包括文件最后更改日期、文件名、文件大小和文件类型等。

01fieg7vikFP.png

获取到的File对象

HTML5还提供了FileReader对象,通过FileReader可以读取File对象对应文件的内容。

var reader = new FileReader();
reader.onload = function(){
    console.log(this.result);
}
reader.readAsText(File);

上述代码中readAsText是以文本方式读取文件内容,还可以通过readAsDataURL方式将文件内容读取成dataURL,或者通过readAsBinaryString方式将文件内容读取成二进制字符串,以及readAsArrayBuffer方法读取二进制原始缓存区。

下面我们回到Chrome应用中。首先通过chooseEntry方法以openFile的方式获取fileEntry:

chrome.fileSystem.chooseEntry({type: 'openFile'}, function(fileEntry){
    //We'll do something with fileEntry later
});

之后通过FileEntry的file方法获取到File对象:

fileEntry.file(function(file){
    //We'll do something with file later
});

最后用FileReader读取file中的内容:

var reader = new FileReader();
reader.onload = function(){
    var text = this.result;
    console.log(text);
    //do something with text
}
reader.readAsText(file);

将上面这三个过程连起来就可以得到如下代码:

chrome.fileSystem.chooseEntry({type: 'openFile'}, function(fileEntry){
    fileEntry.file(function(file){
        var reader = new FileReader();
        reader.onload = function(){
            var text = this.result;
            console.log(text);
            //do something with text
        }
        reader.readAsText(File);
    });
});

当然如果读取的文件中,并非是文本类型的数据,可以使用readAsBinaryString方式直接读取文件的二进制数据。

01fiehFZtHRC.png

读取文件内容

7.4遍历目录

通过Entry的createReader方法可以创建DirectoryReader对象,而DirectoryReader对象的readEntries方法又可以读取出当前目录下的一级子目录和文件,依次类推就可以遍历整个目录。

下面我们来实践写一个遍历目录的函数。

首先通过chooseEntry方法获取Entry:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    //We'll do something with Entry later
});

接下来我们来获取Entry下的子目录和文件:

var dirReader = Entry.createReader();
dirReader.readEntries (function(Entries) {
    //We'll do something with Entries later
}, errorHandler);

获取到Entries之后要对其中的每个元素进行判断是目录还是文件,如果是文件直接输出文件名,如果还是目录,则继续遍历:

for(var i=0; i<Entries.length; i++){
    //We'll print name of this Entry
    if(Entries[i].isDirectory){
        //We'll get sub Entries for this Entry
    }
}

基本的过程已经搞清楚了,现在开始编写打印Entry名的函数。我们希望设计成以下输出格式:

The full path of the selected Entry

|-Entry1
| |-sub Entry1
| | |-File1 in sub Entry1
| |-File1 in Entry1
| |-File2 in Entry1
|-File1
|-File2

所以显示Entry需要指定当前的目录深度以输出相应的层次格式:

function echoEntry(depth, Entry){
    var tree = '|';
    for(var i=0; i<depth-1; i++){
        tree += ' |';
    }
    console.log(tree+'-'+Entry.name);
}

然后我们将获取子目录和文件的代码也封装成一个函数以便复用:

function getSubEntries(depth, Entry){
    var dirReader = Entry.createReader();
    dirReader.readEntries (function(Entries) {
        for(var i=0; i<Entries.length; i++){
            echoEntry(depth+1, Entries[i]);
            if(Entries[i].isDirectory){
                getSubEntries(depth+1, Entries[i]);
            }
        }
    }, errorHandler);
}

最后在chooseEntry获取到Entry之后调用getSubEntries函数:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    console.log(Entry.fullPath);
    getSubEntries(0, Entry);
});

别忘了定义errorHandler函数用于抓取错误:

function errorHandler(e){
    console.log(e.message);
}

但是细心的读者会发现按照上面的写法会先显示一级目录,而后显示二级目录以此类推,并不是像我们所设计的那样展示实际的目录结构。这是因为getSubEntries函数得到的结果是以回调的形式传递的,也就是说getSubEntries函数未执行结束并不会阻塞循环体。这个问题只是在显示结果时会造成一点小麻烦,在实际遍历目录时我们并不在意哪些先得到哪些后得到。但为了使本小节的例子更加完善,现将代码修改如下:

var loopEntriesButton = document.getElementById('le');
loopEntriesButton.addEventListener('click', function(e) {
    chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
        document.getElementById('loopEntry').innerText = Entry.fullPath;
        getSubEntries(0, Entry, document.getElementById('loopEntry'));
    });
});
function getSubEntries(depth, Entry, parent){
    var dirReader = Entry.createReader();
    dirReader.readEntries(function(Entries) {
        for(var i=0; i<Entries.length; i++){
            var newParent = document.createElement('div');
            newParent.id = Date.now();
            newParent.innerText = echoEntry(depth+1, Entries[i]);
            parent.appendChild(newParent);
            if(Entries[i].isDirectory){
                getSubEntries(depth+1, Entries[i], newParent);
            }
        }
    }, errorHandler);
}
function echoEntry(depth, Entry){
    var tree = '|';
    for(var i=0; i<depth-1; i++){
        tree += ' |';
    }
    return (tree+'-'+Entry.name);
}

对应的HTML为:

<input type="button" id="le" value="Loop Entries" />
<div id="loopEntry"></div>

最终运行的结果如下图所示:

01fifDH95pnc.png

遍历目录所得到的结果

7.5创建及删除目录和文件

在7.1节中介绍过,Entry的getDirectory和getFile方法可以获取和创建子目录和文件,在本节将主要讲解创建目录和文件。同时也会介绍删除目录和文件的方法。

在调用getDirectory方法时,如果在参数对象中指定create属性为true,则会创建相应的子目录,如:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    Entry.getDirectory('new_folder', {create: true}, function(subEntry) {
        //We'll do something with subEntry later
    }, errorHandler);
});

这将在用户所选择的目录下创建一个名为new_folder的子文件夹。同时也可以指定参数对象exclusive属性为true,这将避免创建同名子目录——如果一旦创建的目录名与一已存在的子目录相同,会返回错误,而不会自动使用其他目录名。

01fihuD6YU57.png

当指定exclusive为true,且创建同名目录时会抛出错误

同样在调用getFile方法时,参数对象中指定create属性为true会创建文件:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    Entry.getFile('log.txt', {create: true}, function(fileEntry) {
        //We'll do something with fileEntry later
    }, errorHandler);
});

创建文件与创建目录基本相同,指定exclusive属性为true时,创建同名文件也会引起错误,所得到的错误信息与目录相同。

除了在用户选择的目录下创建文件外,也可以指定chooseEntry方法的打开类型为saveFile,这样用户看到的将不是一个目录选择窗口,而是一个另存为窗口:

chrome.fileSystem.chooseEntry({
    type: 'saveFile',
    suggestedName: 'log.txt'
}, function(fileEntry) {
    //We'll do something with fileEntry later
});

通过指定suggestedName的值可以在另存为窗口中给出默认文件名,但用户可以自行更改这个文件名。

01fihuNqcuLo.png

带有默认文件名的另存为窗口

Entry和FileEntry的remove方法可以删除自身:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    Entry.getDirectory('new_folder', {}, function(subEntry) {
        subEntry.remove(function(){
            console.log('Directory has been removed.');
        }, errorHandler);
    }, errorHandler);
    Entry.getFile('log.txt', {}, function(fileEntry) {
        fileEntry.remove(function(){
            console.log('File has been removed.');
        }, errorHandler);
    }, errorHandler);
});

对于目录来说,只有当目录不包含任何文件和子目录的时候remove方法才会调用成功,否则会报错。如果想删除包含内容的目录,需要使用removeRecursively方法:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    Entry.getDirectory('new_folder', {}, function(subEntry) {
        subEntry.removeRecursively(function(){
            console.log('Directory has been removed.');
        }, errorHandler);
    }, errorHandler);
});

7.6写入文件

通过FileEntry的createWriter方法可以获取FileWriter对象,通过FileWriter可以对文件进行写操作:

fileEntry.createWriter(function(fileWriter) {
    //We'll do something with fileWriter later
}, errorHandler);

对于FileEntry,可以通过Entry的getFile方法获取,也可以直接通过指定saveFile类型的chooseEntry获得:

chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(Entry) {
    Entry.getFile('log.txt', {}, function(fileEntry) {
        fileEntry.createWriter(function(fileWriter) {
            //We'll do something with fileWriter later
        }, errorHandler);
    }, errorHandler);
});

chrome.fileSystem.chooseEntry({
    type: 'saveFile',
    suggestedName: 'log.txt'
}, function(fileEntry) {
    fileEntry.createWriter(function(fileWriter) {
        //We'll do something with fileWriter later
    }, errorHandler);
});

由于之后的操作都是针对FileWriter的,下面将只讲解与FileWriter相关的内容。


7.6.1Typed Array

Typed Array(类型数组)为JavaScript直接处理原始二进制数据提供了接口。随着HTML5功能的增加,JavaScript处理的数据已不仅仅局限于数字和字符串等基本类型,也会处理图像、声音、视频等更加复杂的数据,所以JavaScript需要一个直接操作原始二进制数据的接口。有关Typed Array草案和WebGL的内容可以通过http://www.khronos.org/registry/typedarray/specs/latest/查看。

Typed Array接口定义了一类固定长度的,可以直接获取缓存区数据的数组类型,ArrayBuffer类型。可以通过new ArrayBuffer(length)来创建一个长度为length字节的二进制缓存区,如:

var buf = new ArrayBuffer(8);

创建了一个长度为8字节(64位)的ArrayBuffer。

ArrayBuffer类型的数据不可以直接读写,需要再构建ArrayBufferView类型数据才可以进行操作。那么ArrayBuffer和ArrayBufferView是什么样的关系呢?ArrayBuffer是最原始的二进制数据,它没有附加任何信息,如数据是如何构造的。而ArrayBufferView则指定了原始二进制数据应该被如何看待——多少位被看做一个基本处理单元。为更加直观阐述这一关系,现举例如下:

var buf = new ArrayBuff(8);

此时对应于buf的数据是8字节(64位),数据结构为:

+----+-+-+-+-+-+-+-+-+
|byte|0|1|2|3|4|5|6|7|
+----+-+-+-+-+-+-+-+-+

如果通过Uint32Array这一ArrayBufferView来格式化buf数据:

var uintBuf = new Uint32Array(buf);

则uintBuf的数据结构为:

+----+-+-+-+-+-+-+-+-+
|byte|0|1|2|3|4|5|6|7|
+----+-+-+-+-+-+-+-+-+
|uint|   0   |   1   |
+----+-+-+-+-+-+-+-+-+

在处理uintBuf数据时,JavaScript会自动以一个uint为单位读写数组。

需要说明的是,创建ArrayBufferView并不会改变ArrayBuffer数据,它只是定义了ArrayBuffer的操作方式,实际上多个ArrayBufferView可以指向同一个ArrayBuffer,但最终对ArrayBufferView的操作都是对ArrayBuffer数据的操作。

ArrayBufferView一共有8种,分别是Int8Array、Uint8Array、Int16Array、Uint16Array、Int32Array、Uint32Array、Float32Array和Float64Array,名称中间的数字代表格式化后数据基本单元位(bit)的长度。

ArrayBufferView也可以指定ArrayBuffer中数据的起止位置,如:

var partUintBuf = new Uint8Array(buf, 3, 4);

这样partUintBuf[0]将指向buf的第4个字节,partUintBuf[1]将指向buf的第5个字节……而partUintBuf这个数组只有4个元素。

下面我们来将一个ArrayBuffer数据按照字符串的方式读取出来。首先在JavaScript中字符类型(String)是占16位的,所以应该使用Uint16Array这个ArrayBufferView指定读取格式:

var stringBuf = new Uint16Array(buf);

这样stringBuf中的每个元素保存的就都是字符的Unicode码了,再使用fromCharCode方法转换成字符就可以了。但是fromCharCode方法需要传递多个参数:

String.fromCharCode(num0, num1, ..., numX);

而不是一个数组:

String.fromCharCode([num0, num1, ..., numX]);

可是我们获得的stringBuf是一个数组,所以不能直接传给fromCharCode。当然可以使用一个循环将每个Unicode码进行转换,之后再拼接起来,但有简单的方法,apply方法。apply方法可以将一个对象的方法应用到另一个对象上,同时改变原方法中的this替换为指定的值。虽然看着有点乱,但这不是我们关心的,重要的是它可以自动将一个数组中的元素转化为函数的参数列表,即foo.apply(null, [a, b, c])等同于foo(a, b, c),这正是我们所需要的。所以将stringBuf转换为字符串的方法就是:

String.fromCharCode.apply(null, stringBuf);

将stringBuf变量省略,就可以得到如下ArrayBuffer转换为String的函数:

function ab2str(buf){
    return String.fromCharCode.apply(null, new Uint16Array(buf));
}

7.6.2Blob对象

Blob对象是对二进制数据的封装,它介于ArrayBuffer和应用层面数据之间。创建Blob对象非常简单,只需指定数据内容和数据类型即可:

var str = 'Internet Explorer is a good tool to download Chrome.';
var oneBlob = new Blob([str], {type: 'text/plain'});

值得注意的是创建Blob对象时的第一个参数永远都是一个数组,即使只有一个元素。第二个参数是创建Blob对象的可选参数,目前只包含type属性,指定Blob对象数据的类型,值为MIME。如果不指定type的值,则type默认为一个空字符串。

创建Blob对象时可以通过字符串指定数据,如上例代码;也可以通过ArrayBuffer、ArrayBufferView和Blob类型数据,还可以是它们的组合,如:

var str = 'Internet Explorer is a good tool to download Chrome.';
var ab = new ArrayBuffer(8);
var abv = new Unit16Array(ab, 2, 2);
var oneBlob = new Blob([str], {type: 'text/plain'});
var anotherBlob = new Blob([ab, abv, oneBlob]);

当通过一个Blob被作为另一个Blob的数据时,它的类型会被忽略,即使数据数组中只有它一个元素时,如:

var oneBlob = new Blob(['Hello World.'], {type: 'text/plain'});
var anotherBlob = new Blob([oneBlob]);

anotherBlob的类型不是'text/plain',而是一个空字符串(因为创建时没有指定)。

Blob对象有两个属性,分别是size和type,其中size为Blob数据的字节长度,type为指定的数据类型,两者均只读。

Blob对象还有两种方法,分别是slice和close。slice方法与String中的分割非常像,只不过在Blob中分割的是二进制数据。如:

var oneBlob = new Blob(['Hello World.'], {type: 'text/plain'});
var anotherBlob = oneBlob.slice(2, 4, 'text/plain');

slice方法不会将原始Blob对象的类型传递给新的Blob对象,如果不指定新的Blob对象类型,其类型是一个空字符串。

close方法用于永久删除Blob对象释放空间,一旦Blob被关闭,它将永远无法被再次调用。

7.6.3FileWriter对象

在本节开始介绍过,通过FileWriter可以对文件进行写操作,下面来详细介绍FileWriter相关的内容。

FileWriter有两个属性,分别是length和position。其中length为文件的长度,position为指针的当前位置,即在文件中写入下一个数据的位置。两者均只读。

另外FileWriter还有三种方法,分别是write、seek和truncate。其中write方法用来写入数据,数据类型为Blob。如:

fileWriter.write(new Blob(['Hello World'], {type: 'text/plain'}));

可以通过onwrite和onwriteend监听数据开始写入和写入完毕事件:

fileWriter.onwrite = function(){
    console.log('Write begin.');
}
fileWriter.onwriteend = function(){
    console.log('Write complete.');
}

seek方法用于移动指针到文件指定位置,之后的写操作将从指针指向的位置开始。如果seek给出的偏移量为负数,则将指针移动到距文件末端n个字节的位置。如果seek给出的偏移量为负数且绝对值比文件长度大,则将指针指向0。如果偏移量比文件长度大,则指向文件末端。

//set position to beginning of the file
fileWriter.seek(0);
//set position to subtracting 5 from file length
fileWriter.seek(-5);
//set position to end of the file
fileWriter.seek(fileWriter.length);

truncate方法用于更改文件长度,如果文件之前的长度比给定的值小,则以0填补,如果比给定的大,则舍弃超出部分的数据。

注意,如果truncate将文件长度缩小,而文件的指针又处于更改长度后文件范围之外(如将文件长度更改为10,而文件指针的位置在20),那么新写入的数据将不会出现在文件中!在调用truncate方法后,一定记得检查指针位置是否依然在文件内部1。

1 W3C标准中指明truncate的值必须大于当前指针位置,但实际发现突破这一限制时,Chrome依然可以成功执行。

结合前面的内容,我们就可以得到完整的写入文件的代码了:


chrome.fileSystem.chooseEntry({
    type: 'saveFile',
    suggestedName: 'log.txt'
}, function(fileEntry) {
    fileEntry.createWriter(function(fileWriter) {
        fileWriter.write(new Blob(['Hello World'], {type: 'text/plain'}));
    }, errorHandler);
});

7.7复制及移动目录和文件

Entry和FileEntry均有copyTo和moveTo方法用来复制和移动目录和文件。

Entry.copyTo(newEntry, 'new_Entry_name', function(copiedEntry){
    console.log('Entry moved.');
}, errorHandler);
Entry.moveTo(newEntry, 'new_Entry_name', function(movedEntry){
    console.log('Entry copied.');
}, errorHandler);
fileEntry.copyTo(newEntry, 'new_fileEntry_name', function(copiedFileEntry){
    console.log('fileEntry copied.');
}, errorHandler);
fileEntry.moveTo(newEntry, 'new_fileEntry_name', function(movedFileEntry){
    console.log('fileEntry moved.');
}, errorHandler);

如果不指定新的名称,则使用目录和文件原来的名称。

对于moveTo方法,不可以:

将目录移动到自身路径或其子目录路径下;

在其父系目录下移动且不指定新的名称;

将文件移动到已被其他目录占用的路径;

将目录移动到已被其他文件占用的路径;

将目录移动到一个非空目录占用的路径。

对于copyTo方法,不可以:

将一个目录复制到自身路径或其子目录路径下;

在其父系目录下复制且不指定新的名称;

将文件复制到已被其他目录占用的路径;

将目录复制到已被其他文件占用的路径;

将目录复制到一个非空目录占用的路径。










-----------------------------------------------------
转载请注明来源此处
原地址:#

-----网友评论----
暂无评论
-----发表评论----
微网聚博客乐园 ©2014 blog.mn886.net 鲁ICP备14012923号   网站导航