JS组件系列——在ABP中封装BootstrapTable
前言:关于ABP框架,博主关注差不多有两年了吧,一直迟迟没有尝试。一方面博主觉得像这种复杂的开发框架肯定有它的过人之处,系统的稳定性和健壮性比一般的开源框架肯定强很多,可是另一方面每每想到它繁琐的封装和复杂的开发流程就望而却步,就这样迟迟没有行动。最近在项目里面用到了ABP框架,没办法,只有硬着头皮上了。经过这一段时间的熟悉,算是对这个框架有了一个大概的了解。今天来分享下如何在ABP框架的模式里面使用bootstrapTable组件。
本文原创地址:http://www.cnblogs.com/landeanfen/p/7261651.html
一、关于ABP
ABP是“ASP.NET Boilerplate Project (ASP.NET样板项目)”的简称,它是一个成熟的开源框架,基于DDD+Repository模式,自带Zero权限和认证模块,避免了从零开始搭建框架的烦恼。关于ABP的框架优势就此打住,因为这样说下去要说三天三夜,脱离文本主题。
关于ABP的入门,博主不想说太多,园子里面tkb至简和阳光铭睿有很多入门级的文章,有兴趣的可以了解下,还是给出它的官网和开源地址。
ABP官方网站:http://www.aspnetboilerplate.com
ABP开源项目:https://github.com/aspnetboilerplate
PS:如果你不愿意去看它的源码,可以直接查看ABP官网上面的演示地址:https://aspnetzero.com/?ref=abptmplpage

点击CREATE MY DEMO按钮,系统会自动为你生成演示地址

进入对应的Demo URL

使用演示的用户名和密码登陆进去

可以看到Zero模块的实现效果。
二、jTable在ABP中的运用
如果你下载ABP的源码,并且选择的是混合开发模式(ABP提供了两种开发模式,一种是基于MVVM的Angular.js的模式;另一种就是MVC+jQuery的混合开发模式),如下图:

当你Down下来源码之后你就会发现,ABP的源码里面的UI部分的表格都是使用jTable去实现的。为什么会用jTable?原因很简单,jTable是ABP的作者kalkan写的一款开源插件,自己写的肯定用自己的东西喽。下面jTable的效果来一发。


来一个jtable的父子表:

如果是不带父子表的简单表格,其实jTable的效果其实还行,可是加上一些复杂的功能之后,那一片片蓝色的区域不忍直视,并且jTable的api还有待完善,很多需要的功能都需要自己去实现,于是就接到了将所有的表格组件换成BootstrapTable的需求,才有了今天的主题:在ABP中封装BootstrapTable。
三、Bootstrap Table在ABP中的封装
接到需求,博主各种百度、各种谷歌,都找不到Bootstrap Table组件在ABP中的封装,有的只是在ABP的项目里面简单的用传统的方式去初始化组件,这并不是博主想要的。说到这里不得不说一下,如果你使用ABP开发的过程中遇到一些难题,你会发现很难从百度里面搜索到相关答案,谷歌里面有时能找到,但大部分都是英文社区,所以如果你英文较弱,在查找资料上面会很吃亏,有时一个简单的配置问题需要折腾很久。
1、jTable在ABP项目里面的初始化
首先来看看jTable在一般的ABP项目里面是如何初始化的。比如我们在Application里面有一个如下的接口和实现

public interface IRequisitionAppService : IApplicationService
{
Task<PagedResultDto<RequisitionListDto>> GetRequisitionListAsync(GetRequisitionListInput input);
}
[AbpAuthorize(OrderAppPermissions.Pages_Order_Requisition)]
public class RequisitionAppService : AbpZeroTemplateAppServiceBase, IRequisitionAppService
{
private readonly IRepository<Requisition, long> _requisitionRepository;
public RequisitionAppService(IRepository<Requisition, long> requisitionRepository)
{
_requisitionRepository = requisitionRepository;
} public async Task<PagedResultDto<RequisitionListDto>> GetRequisitionListAsync(GetRequisitionListInput input)
{
var query = _requisitionRepository.GetAll()
.WhereIf(input.Status != null, w => (int)w.Status == input.Status.Value)
.WhereIf(
!input.Filter.IsNullOrWhiteSpace(),
u =>
u.No.Contains(input.Filter) ||
u.Remark.Contains(input.Filter)
); var count = await query.CountAsync(); var list = await query
.OrderBy(input.Sorting)
.PageBy(input)
.ToListAsync(); var dtos = list.MapTo<List<RequisitionListDto>>();
return new PagedResultDto<RequisitionListDto>(
count,
dtos
);
} }
然后我们前端有一个页面的列表数据从这个接口GetRequisitionListAsync()获取
<div class="portlet-body">
<div id="dataListTable"></div>
</div>
(function () {
$(function () {
var _$dataListTable = $('#dataListTable');
var _service = abp.services.app.requisition;
_$dataListTable.jtable({
paging: true,
sorting: true,
selecting: true,
actions: {
listAction: {
method: _service.getRequisitionListAsync
}
},
fields: {
id: {
key: true,
list: false
},
details: {
width: '1%',
sorting: false,
edit: false,
create: false,
listClass: 'child-opener-image-column',
display: function (detailData) {
var $img = $('<img class="child-opener-image" src="/Common/Images/list_metro.png" title="申购明细" />');
$img.click(function () {
_$dataListTable.jtable('openChildTable',
$img.closest('tr'),
{
title: "申购明细",
showCloseButton: true,
actions: {
listAction: {
method: _service.getRequisitionDetailListByIdAsync
}
},
fields: {
materialClassParentNameAndName: {
title: app.localize('MaterialClassName'),
width: '8%'
},
materialInfoTypeNo: {
title: app.localize('TypeNo'),
width: '5%'
},
materialInfoLengthDisplayName: {
title: app.localize('LengthDisplayName'),
width: '3%'
},
materialInfoWeight: {
title: app.localize('Weight'),
width: '5%',
display: function (data) {
return data.record.materialInfoMinWeight + '-' + data.record.materialInfoMaxWeight;
}
},
materialInfoMouldTypeDisplayName: {
title: app.localize('MouldTypeDisplayName'),
width: '6%'
},
materialInfoProductionRemark: {
title: app.localize('ProductionRemark'),
width: '8%'
},
materialInfoBundleCountDisplayName: {
title: app.localize('BundleCountDisplayName'),
width: '3%'
},
materialInfoUnitDisplayName: {
title: app.localize('UnitDisplayName'),
width: '3%'
},
materialInfoProcessCost: {
title: app.localize('ProcessCost'),
width: '6%'
},
materialInfoProductRemark: {
title: app.localize('ProductRemark'),
width: '6%'
},
materialInfoRemark: {
title: app.localize('Remark'),
width: '6%'
},
count: {
title: app.localize('申购数量'),
width: '6%'
},
remark: {
title: app.localize('申购备注'),
width: '6%'
}
}
}, function (data) {
data.childTable.jtable('load',
{ requisitionId: detailData.record.id }
);
});
});
return $img;
}
},
no: {
title: "申购单号",
width: '20%'
},
creatorUserName: {
title: "申购人",
width: '20%'
},
creationTime: {
title: "申购时间",
width: '10%',
display: function (data) {
return moment(data.record.creationTime).format('YYYY-MM-DD HH:mm:ss');
}
},
sumCount: {
title: "总数",
width: '10%'
},
status: {
title: "状态",
width: '20%',
display: function (data) {
if (data.record.status === app.order.requisitionAuditStatus.audit)
return '<span class="label label-info">' + app.localize('Autdit') + '</span>'
else if (data.record.status === app.order.requisitionAuditStatus.auditPass)
return '<span class="label label-success">' + app.localize('Pass') + '</span>'
else if (data.record.status === app.order.requisitionAuditStatus.auditReject)
return '<span class="label label-danger">' + app.localize('Reject') + '</span>'
else if (data.record.status === app.order.requisitionAuditStatus.delete)
return '<span class="label label-danger">' + app.localize('Abandon') + '</span>'
else
return '<span class="label label-danger">' + app.localize('Unknown') + '</span>'
}
}
}
});
});
})();
得到如下效果:

代码释疑:
(1) var _service = abp.services.app.requisition; 这一句声明当前页面需要使用哪个服务。
(2) _service.getRequisitionListAsync 这一句对应的是服务调用的方法,你会发现在后台方法名是GetRequisitionListAsync(),而在js里面却变成了getRequisitionListAsync(),我们暂且称之为“潜规则”。
2、bootstrapTable在ABP项目里面的封装
通过上述代码你会发现,ABP在application层里面定义的方法,最终会生成某一些js对应的function,这里难点来了。我们找遍了bootstrapTable组件的api,都没有通过某一个function去获取数据的啊。这可如何是好?为这个问题,博主折腾了两天。最开始博主想,function最终还不是要换成http请求的,我们只要拿到http请求的url,然后将function转换为url不就行了么:

我们使用bootstrapTable组件初始化的时候声明 {url:'/api/services/app/requisition/GetRequisitionListAsync'} 这样不就行了么?呵呵,经过测试,这样确实能正确取到数据。但是不够理想,因为这前面的前缀是ABP给我们生成的,是否会变化我们尚且不说,给每一个url加上这么一长串着实看着很不爽,于是进一步想,是否我们的bootstrapTable也可以使用function去初始化呢,组件没有,难道我们就不能给他扩展一个吗?我们不用url获取数据,通过调用这个function取到数据,然后将数据渲染到组件不就行了。思路有了,那么这里有两个难题:一是如何将原来url的方式变成这里的调用function的方式呢?二是参数的封装。经过查看组件的源码发现,如果是服务端分页,组件最终是进入到initServer()这个方法去获取数据,然后渲染到页面上面的,组件原始的initServer()方法如下:
BootstrapTable.prototype.initServer = function (silent, query) {
var that = this,
data = {},
params = {
pageSize: this.options.pageSize === this.options.formatAllRows() ?
this.options.totalRows : this.options.pageSize,
pageNumber: this.options.pageNumber,
searchText: this.searchText,
sortName: this.options.sortName,
sortOrder: this.options.sortOrder
},
request;
if (!this.options.url && !this.options.ajax) {
return;
}
if (this.options.queryParamsType === 'limit') {
params = {
search: params.searchText,
sort: params.sortName,
order: params.sortOrder
};
if (this.options.pagination) {
params.limit = this.options.pageSize === this.options.formatAllRows() ?
this.options.totalRows : this.options.pageSize;
params.offset = this.options.pageSize === this.options.formatAllRows() ?
0 : this.options.pageSize * (this.options.pageNumber - 1);
}
}
if (!($.isEmptyObject(this.filterColumnsPartial))) {
params['filter'] = JSON.stringify(this.filterColumnsPartial, null);
}
data = calculateObjectValue(this.options, this.options.queryParams, [params], data);
$.extend(data, query || {});
// false to stop request
if (data === false) {
return;
}
if (!silent) {
this.$tableLoading.show();
}
request = $.extend({}, calculateObjectValue(null, this.options.ajaxOptions), {
type: this.options.method,
url: this.options.url,
data: this.options.contentType === 'application/json' && this.options.method === 'post' ?
JSON.stringify(data) : data,
cache: this.options.cache,
contentType: this.options.contentType,
dataType: this.options.dataType,
success: function (res) {
res = calculateObjectValue(that.options, that.options.responseHandler, [res], res);
that.load(res);
that.trigger('load-success', res);
},
error: function (res) {
that.trigger('load-error', res.status, res);
},
complete: function () {
if (!silent) {
that.$tableLoading.hide();
}
}
});
if (this.options.ajax) {
calculateObjectValue(this, this.options.ajax, [request], null);
} else {
$.ajax(request);
}
};
组件原始initServer()方法
代码不难读懂,解析参数,整合参数,得到参数,发送ajax请求,在success事件里面将得到的数据渲染到界面。读懂了这段代码,我们再来封装function就容易多了。
最终我们封装的代码如下:
(function ($) {
'use strict';
//debugger;
//通过构造函数获取到bootstrapTable里面的初始化方法
var BootstrapTable = $.fn.bootstrapTable.Constructor,
_initData = BootstrapTable.prototype.initData,
_initPagination = BootstrapTable.prototype.initPagination,
_initBody = BootstrapTable.prototype.initBody,
_initServer = BootstrapTable.prototype.initServer,
_initContainer = BootstrapTable.prototype.initContainer;
//重写
BootstrapTable.prototype.initData = function () {
_initData.apply(this, Array.prototype.slice.apply(arguments));
};
BootstrapTable.prototype.initPagination = function () {
_initPagination.apply(this, Array.prototype.slice.apply(arguments));
};
BootstrapTable.prototype.initBody = function (fixedScroll) {
_initBody.apply(this, Array.prototype.slice.apply(arguments));
};
BootstrapTable.prototype.initServer = function (silent, query) {
//构造自定义参数
for (var key in this.options.methodParams) {
$.fn.bootstrapTable.defaults.methodParams[key] = this.options.methodParams[key];
}
//如果传了url,则走原来的逻辑
if (this.options.url) {
_initServer.apply(this, Array.prototype.slice.apply(arguments));
return;
}
//如果定义了abpMethod,则走abpMethod的逻辑
if (!this.options.abpMethod) {
return;
}
var that = this,
data = {},
params = {
pageSize: this.options.pageSize === this.options.formatAllRows() ?
this.options.totalRows : this.options.pageSize,
pageNumber: this.options.pageNumber,
searchText: this.searchText,
sortName: this.options.sortName,
sortOrder: this.options.sortOrder
},
request;
//debugger;
if (this.options.queryParamsType === 'limit') {
params = {
search: params.searchText,
sort: params.sortName,
order: params.sortOrder
};
if (this.options.pagination) {
params.limit = this.options.pageSize === this.options.formatAllRows() ?
this.options.totalRows : this.options.pageSize;
params.offset = this.options.pageSize === this.options.formatAllRows() ?
0 : this.options.pageSize * (this.options.pageNumber - 1);
}
}
if (!($.isEmptyObject(this.filterColumnsPartial))) {
params['filter'] = JSON.stringify(this.filterColumnsPartial, null);
}
data = $.fn.bootstrapTable.utils.calculateObjectValue(this.options, this.options.queryParams, [params], data);
$.extend(data, query || {});
// false to stop request
if (data === false) {
return;
}
if (!silent) {
this.$tableLoading.show();
}
this.options.abpMethod(data).done(function (result) {
result = $.fn.bootstrapTable.utils.calculateObjectValue(that.options, that.options.responseHandler, [result], result);
that.load(result);
that.trigger('load-success', result);
});
request = $.extend({}, $.fn.bootstrapTable.utils.calculateObjectValue(null, this.options.ajaxOptions), {
type: this.options.method,
url: this.options.url,
data: this.options.contentType === 'application/json' && this.options.method === 'post' ?
JSON.stringify(data) : data,
cache: this.options.cache,
contentType: this.options.contentType,
dataType: this.options.dataType,
success: function (res) {
debugger;
res = $.fn.bootstrapTable.utils.calculateObjectValue(that.options, that.options.responseHandler, [res], res);
that.load(res);
that.trigger('load-success', res);
},
error: function (res) {
that.trigger('load-error', res.status, res);
},
complete: function () {
if (!silent) {
that.$tableLoading.hide();
}
}
});
if (this.options.ajax) {
$.fn.bootstrapTable.utils.calculateObjectValue(this, this.options.ajax, [request], null);
} else {
$.ajax(request);
}
}
BootstrapTable.prototype.initContainer = function () {
_initContainer.apply(this, Array.prototype.slice.apply(arguments));
};
abp.bootstrapTableDefaults = {
striped: false,
classes: 'table table-striped table-bordered table-advance table-hover',
pagination: true,
cache: false,
sidePagination: 'server',
uniqueId: 'id',
showRefresh: false,
search: false,
method: 'post',
//toolbar: '#toolbar',
pageSize: 10,
paginationPreText: '上一页',
paginationNextText: '下一页',
queryParams: function (param) {
//$.fn.bootstrapTable.defaults.methodParams.propertyIsEnumerable()
var abpParam = {
Sorting: param.sort,
filter: param.search,
skipCount: param.offset,
maxResultCount: param.limit
};
for (var key in $.fn.bootstrapTable.defaults.methodParams) {
abpParam[key] = $.fn.bootstrapTable.defaults.methodParams[key];
}
return abpParam;
},
responseHandler: function (res) {
if (res.totalCount)
return { total: res.totalCount, rows: res.items };
else
return { total: res.result.totalCount, rows: res.result.items };
},
methodParams: {},
abpMethod: function () { }
};
$.extend($.fn.bootstrapTable.defaults, abp.bootstrapTableDefaults);
})(jQuery);
代码释疑:增加两个参数 methodParams: {},abpMethod: function () { } 来获取abp的function和参数,然后获取数据的时候如果定义了abpMethod,则通过function获取数据,否则还是走原来的逻辑。
然后我们调用就简单了
//选取界面上要先数据的表格
var _$SendOrdersTable = $('#SendOrdersTable');
//获取服务层方法
var _SendOrderService = abp.services.app.sendOrder; _$SendOrdersTable.bootstrapTable({
abpMethod: _SendOrderService.getSendOrderListAsync,
detailView: true,
onExpandRow: function (index, row, $detail) {
var cur_table = $detail.html('<table></table>').find('table');
$(cur_table).bootstrapTable({
showRefresh: false,
search: false,
pagination: false,
abpMethod: _SendOrderService.getSendOrderDetailListAsync,
methodParams: { SendOrderId: row.id },
columns: [
{
field: 'materialClassName',
title: app.localize('MaterialClassName'),
width: '8%'
},
{
field: 'typeNo',
title: app.localize('TypeNo'),
width: '8%'
}
]
});
},
columns: [{
field: 'no',
title: app.localize('SendOrderNO'),
align: 'center'
},
{
field: 'supplierName',
title: app.localize('SupplierName'),
align: 'center'
},
{
title: app.localize('SendOrderTime'),
align: 'center',
field: 'createdDate',
formatter: function (data) {
return moment(data).format('YYYY-MM-DD HH:mm:ss');
}
}, {
field: 'status',
align: 'center',
title: app.localize('SendOrderStatus'),
formatter: function (data) {
var value = "";
if (data == 1) {
value = '<span class="label label-info">' + app.localize('Autdit') + '</span>';
}
else if (data == 2) {
value = '<span class="label label-success">' + app.localize('Pass') + '</span>';
}
else if (data == 3) {
value = '<span class="label label-default">' + app.localize('Reject') + '</span>';
}
else
value = '<span class="label label-default">' + app.localize('Abandon') + '</span>';
return value;
}
}, {
field: 'createName',
align: 'center',
title: app.localize('SendOrderCreator'),
}, {
field: 'sumCount',
align: 'center',
title: app.localize('SendOrderTotalCount'),
},
]
});
得到如下效果

四、总结
通过以上一个简单的封装,顺利将bootstrapTable融入到了ABP的操作方式里面。是不是很easy!在使用ABP的过程中,博主还做了其他一些封装,以后有机会再来介绍,关于ABP的技术交流欢迎联系博主。这一篇就到这里,欢迎交流。如果你觉得本文能够帮助你,可以右边随意 打赏 博主,打赏后可以获得博主永久免费的技术支持。
本文原创出处:http://www.cnblogs.com/landeanfen/
欢迎各位转载,但是未经作者本人同意,转载文章之后必须在文章页面明显位置给出作者和原文连接,否则保留追究法律责任的权利
JS组件系列——在ABP中封装BootstrapTable的更多相关文章
- JS组件系列——BootstrapTable 行内编辑解决方案:x-editable
前言:之前介绍bootstrapTable组件的时候有提到它的行内编辑功能,只不过为了展示功能,将此一笔带过了,罪过罪过!最近项目里面还是打算将行内编辑用起来,于是再次研究了下x-editable组件 ...
- JS组件系列——BootstrapTable+KnockoutJS实现增删改查解决方案(四):自定义T4模板快速生成页面
前言:上篇介绍了下ko增删改查的封装,确实节省了大量的js代码.博主是一个喜欢偷懒的人,总觉得这些基础的增删改查效果能不能通过一个什么工具直接生成页面效果,啥代码都不用写了,那该多爽.于是研究了下T4 ...
- JS组件系列——BootstrapTable+KnockoutJS实现增删改查解决方案(三):两个Viewmodel搞定增删改查
前言:之前博主分享过knockoutJS和BootstrapTable的一些基础用法,都是写基础应用,根本谈不上封装,仅仅是避免了html控件的取值和赋值,远远没有将MVVM的精妙展现出来.最近项目打 ...
- JS组件系列——BootstrapTable+KnockoutJS实现增删改查解决方案(二)
前言:上篇 JS组件系列——BootstrapTable+KnockoutJS实现增删改查解决方案(一) 介绍了下knockout.js的一些基础用法,由于篇幅的关系,所以只能分成两篇,望见谅!昨天就 ...
- JS组件系列——自己动手扩展BootstrapTable的 冻结列 功能:彻底解决高度问题
前言:一年前,博主分享过一篇关于bootstrapTable组件冻结列的解决方案 JS组件系列——Bootstrap Table 冻结列功能IE浏览器兼容性问题解决方案 ,通过该篇,确实可以实现bo ...
- [转]JS组件系列——BootstrapTable 行内编辑解决方案:x-editable
本文转自:http://www.cnblogs.com/landeanfen/p/5821192.html 阅读目录 一.x-editable组件介绍 二.bootstrapTable行内编辑初始方案 ...
- JS组件系列——BootstrapTable+KnockoutJS实现增删改查解决方案(一)
前言:出于某种原因,需要学习下Knockout.js,这个组件很早前听说过,但一直没尝试使用,这两天学习了下,觉得它真心不错,双向绑定的机制简直太爽了.今天打算结合bootstrapTable和Kno ...
- JS组件系列——自己动手扩展BootstrapTable的treegrid功能
前言:上篇 JS组件系列——自己动手封装bootstrap-treegrid组件 博主自己动手封装了下treegrid的功能,但毕竟那个组件只是一个单独针对树形表格做的,适用性还比较有限.关注博主的 ...
- JS组件系列——分享自己封装的Bootstrap树形组件:jqTree
前言:之前的一篇介绍了下如何封装自己的组件,这篇再次来体验下自己封装组件的乐趣.看过博主博客的园友应该记得之前分享过一篇树形菜单的使用JS组件系列——Bootstrap 树控件使用经验分享,这篇里面第 ...
随机推荐
- Ceph Object Gateway Admin api 获取用户列表问题
按照官方文档使用Admin Ops API 获取用户列表 GET /admin/user时 返回{code: 403, message: Forbidden}这里有两个问题:首先用户列表的请求为 如下 ...
- CoreLocation 框架
获取设备的地理位置和方向 一.概述 CoreLocation框架,它提供了如下几种服务 确定设备的地理位置 高度 方向 或到附近 iBeacon 的相对位置. 这个框架使用所有可用的车载硬件.如 Wi ...
- PHPUnit-函数依赖-数据提供-异常-忽略-自动生成
1. 本文目的 本文目的是收录一些PHPUnit的有用技巧,这些技巧能够为给PHPUnit单元测试带来很多便利.本文将要介绍的技巧如下: 函数依赖测试 数据提供函数 异常测试 跳过忽略测试 自动生成测 ...
- CSS学习笔记10 相对定位,绝对定位与固定定位
文档流中的元素的位置由元素在 (X)HTML 中的位置决定,这就是最原始的普通流,前面讲到的浮动CSS学习笔记08 浮动可以改变元素在文档流中的位置,除了这个我们还可以通过使用CSS的position ...
- Ionic 常用组件解析
Ionic 常用组件解析 $ionicModal(弹出窗口): //创建一个窗口 //此处注意目录的起始位置为app $ionicModal.fromTemplateUrl('app/security ...
- 2017寒假零基础学习Python系列之 印子
今日为2017年2月6日,据在慕课网上学习廖雪峰Python教程也快一周左右了,完全是零基础入门Python,大一上学期粗浅的接触学习了C语言,早就听说过Python语言的大名,又想把Python的爬 ...
- 初学spring boot踩过的坑
一.搭建spring boot环境 maven工程 pom文件内容 <project xmlns="http://maven.apache.org/POM/4.0.0" xm ...
- centos 7 部署Thinksns
因为Thinksns是PHP项目,我们这里部署需要搭建Apache+mysql+php环境. 1.mysql的安装,这里使用yum安装可以解决很多依赖包的问题.由于centos 7 没有自带mysql ...
- FZU 1015 土地划分
Description 在Dukeswood这块土地上生活着一个富有的农庄主和他的几个孩子.在他临终时,他想把他的土地分给他的孩子.他有许多农场,每个农场都是一块矩形土地.他在农场地图上划上一 ...
- RunLoop的简单理解笔记
一句话解释RunLoop:运行任务的循环. 为什么要有RunLoop:解决交互式UI设计中的一个问题,如何快速响应用户输入,如何快速将程序运行结果输出到屏幕? 基本原理:1 将任务分解的足够细 2 每 ...