Uni-app 卡片组件
<template>
<view class="card-content">
<span v-if="cardData.length === 0 && needNoData" class="no-data"
>No data</span
>
<block v-for="(item, itemIndex) of cardData" :key="itemIndex">
<view
class="card-item"
@click="contentClickHandle(item, itemIndex)"
>
<view class="title">
<span @click.stop="codeNameClick(item)" class="titleName">
<u-icon
v-if="codeNameLink"
name="attach"
color="#6dbee2"
size="30"
></u-icon>
{{ codeName === '' ? '' : codeFormat(item[codeName]) }}
</span>
<span v-show="showCreateDate">
{{
subCodeName && item[subCodeName]
? `${item[subCodeName]}`
: item.CreateDate
? formatDate(item.CreateDate)
: item[createDateName]
? formatDate(item[createDateName])
: ''
}}
</span>
</view>
<view class="card-data">
<block v-for="(i, index) of attributesList" :key="index">
<span
class="card-attribute"
:style="{
padding: i.type === 'extends' ? '0' : '10rpx',
}"
>
<span
:style="{ width: `${labelWidth}rpx` }"
@click.stop="showToast(i.label)"
v-if="!(i.type === 'extends')"
>
{{ i.label }}
</span>
<!-- 文件下载
type: file
urlName: 文件 url 对应字段
-->
<span
@click="downloadFile(item[i.urlName], item)"
v-if="i.type === 'file' && i.urlName"
class="file"
@click.stop="stopPropagation"
download
>
<u-icon name="file-text" size="50"></u-icon>
</span>
<!-- 多字段拼接
type: multiField
names: 字段数组,[string, {
name: string,
format: boolean,
formatFunc: Function
}]
separator: 拼接分隔符
-->
<span
v-else-if="i.type === 'multiField' && i.names"
@click.stop="
showToast(
multiFieldHandle(
item,
i.names, // 字段数组
i.separator // 拼接分隔符
)
)
"
>
{{
multiFieldHandle(item, i.names, i.separator)
}}
</span>
<!-- 使用符号表示
useIcon 为 true
iconMap: {
item[i.name]: 字段的值,例如(true/false): {
name: 对应 u-icon 的 name
color: 对应 u-icon 的 color
size: 对应 u-icon 的 size
}
}
-->
<span v-else-if="i.type === 'icon' && i.iconMap">
<u-icon
:name="i.iconMap.get(item[i.name]).name"
:color="i.iconMap.get(item[i.name]).color"
:size="i.iconMap.get(item[i.name]).size"
/>
</span>
<!-- 使用图片显示
type: image
item[i.name]: 图片 url
height: 图片高都 默认 60rpx
mode: 图片展示类型 根据 uni-app image 默认 heightFix
-->
<span
v-else-if="i.type === 'image'"
@click.stop="showImage(item[i.name])"
>
<span
v-if="
item[i.name] !== '' &&
item[i.name] != null
"
:style="{
height: `${
i.height ? i.height : 60
}rpx`,
}"
>
<u-image
:mode="i.mode ? i.mode : 'heightFix'"
:height="i.height ? i.height : 60"
:src="item[i.name]"
></u-image>
</span>
<span v-else>No Image</span>
</span>
<!-- 格式化
type: format
formatFunc: 格式化函数 返回文本或html
-->
<span
v-else-if="
i.type === 'format' &&
i.formatFunc &&
typeof i.formatFunc === 'function'
"
>
<span
v-html="i.formatFunc(item[i.name])"
></span>
</span>
<!--
type: formatDate
日期格式化
-->
<span v-else-if="i.type === 'formatDate'">
<span v-html="formatDate(item[i.name])"></span>
</span>
<!-- 插槽
type: slot
slotName 插槽名称
-->
<span v-else-if="i.type === 'slot' && i.slotName">
<span>
<slot
:name="i.slotName"
:row="item"
:index="itemIndex"
></slot>
</span>
</span>
<!-- app不支持动态插槽,使用固定名称 -->
<span v-else-if="i.type === 'slot1'">
<span>
<slot
name="slot1"
:row="item"
:index="itemIndex"
></slot>
</span>
</span>
<span v-else-if="i.type === 'slot2'">
<span>
<slot
name="slot2"
:row="item"
:index="itemIndex"
></slot>
</span>
</span>
<span v-else-if="i.type === 'slot3'">
<span>
<slot
name="slot3"
:row="item"
:index="itemIndex"
></slot>
</span>
</span>
<!--
type: extends
嵌套机构
childrensMark: 子集名称
getChildrenApi: 子集获取api
-->
<span
v-else-if="
i.type === 'extends' &&
item[i.childrensMark] &&
i.getChildrenApi
"
:style="{ 'padding-right': 0 }"
>
<span
@click.stop="
getChildrenData(i.getChildrenApi, item)
"
class="children-extends-btn"
style="margin: 10rpx 0"
>
<u-icon
name="plus"
style="display: inline-block"
color="#fff"
></u-icon>
</span>
</span>
<!-- 默认显示模式 -->
<span
v-else
class="default"
@click.stop="showToast(item[i.name])"
>{{ item[i.name] }}</span
>
</span>
</block>
</view>
<view class="footer">
<!-- 自定义 footer
hasFooter 为 true
slot: footer
-->
<view v-if="hasFooter">
<slot name="footer" :item="item"></slot>
</view>
<!-- 默认 footer -->
<view
v-if="!hasFooter"
class="default"
:style="
!(needStatusTag && (item.StatusId || item.Status))
? { width: '100%', padding: 0 }
: {}
"
>
<!-- 需要显示 Status -->
<l-steps
v-if="
needStatusTag &&
(item.StatusId || item.Status) &&
!customStatus
"
:id="item[`${idName}`]"
:detail="{
StatusName:
item.StatusName || item.StatusLabel || null,
StatusId: item.StatusId || item.Status,
Blacklist: item.Blacklist,
IsSent: item.IsSent,
IsActive: item.IsActive || null,
}"
></l-steps>
<slot
v-if="customStatus"
name="customStatus"
:item="item"
></slot>
<!-- action 按钮设置 -->
<span
:style="
!(
needStatusTag &&
(item.StatusId || item.Status)
)
? {
width: '100%',
}
: {}
"
>
<u-button
@click.stop="ActionClickHandle(item, itemIndex)"
type="warning"
size="mini"
v-if="hasAction"
:style="
!(
needStatusTag &&
(item.StatusId || item.Status)
)
? {
'border-radius':
'0 0 15rpx 15rpx',
height: '70rpx',
width: '100%',
}
: {}
"
>Action</u-button
>
</span>
</view>
</view>
</view>
</block>
<u-popup
v-model="showChildrens"
class="card-childrens-popup"
v-if="!isChildren"
>
<view class="childrens-header">
<span>Childrens Data</span>
<span>
<u-icon name="close" @click="closeChildrensData"> </u-icon>
</span>
</view>
<u-tabs
:list="titleList"
:current="current"
@change="change"
active-color="#f08300"
bg-color="#e7e7e7"
:is-scroll="true"
></u-tabs>
<c-scroll-view-back-top height="calc(100vh - 185rpx)">
<view slot="content" class="card-childrens">
<c-card
:codeName="codeName"
:cardData="cardChildrenData"
:attributesList="attributesList"
@actionClick="ActionClickHandle"
@click="childrenClick"
:labelWidth="labelWidth"
:needStatusTag="needStatusTag"
:hasFooter="hasFooter"
:hasAction="hasAction"
:needNoData="false"
:isChildren="true"
:customStatus="customStatus"
>
<span slot="slot1" slot-scope="scope">
<slot
name="slot1"
:row="scope.row"
:index="scope.index"
></slot>
</span>
<span slot="slot2" slot-scope="scope">
<slot
name="slot2"
:row="scope.row"
:index="scope.index"
></slot>
</span>
<span slot="slot3" slot-scope="scope">
<slot
name="slot3"
:row="scope.row"
:index="scope.index"
></slot>
</span>
<span slot="customStatus" slot-scope="scope">
<slot name="customStatus" :item="scope.item"></slot>
</span>
</c-card>
</view>
</c-scroll-view-back-top>
</u-popup>
<u-toast ref="uToast" />
</view>
</template>
<script lang="ts">
import {
defineComponent,
reactive,
PropType,
ref,
inject,
} from '@vue/composition-api'
import { formatDate, openFile } from '@/api/utils/util'
import { Attributes } from '@/types'
export interface Attributes {
label: string
name?: string
type?:
| 'file'
| 'multiField'
| 'icon'
| 'image'
| 'format'
| 'slot'
| 'slot1'
| 'slot2'
| 'slot3'
| 'extends'
| 'formatDate'
urlName?: string // 文件 url 对应字段
names?: [
string,
{
name: string
format: boolean
formatFunc: Function
}
] // 字段数组
separator?: string //拼接分隔符
iconMap?: Map<
any,
{
// 字段的值,例如(true/false)
name: string //对应 u-icon 的 name
color: string //对应 u-icon 的 color
size: string //对应 u-icon 的 size
}
>
height?: string // 图片高都 默认 60rpx
mode?: string //图片展示类型 根据 uni-app image 默认 heightFix
formatFunc?: Function // 格式化函数 返回文本或html
slotName?: string // 插槽名称
childrensMark?: string // 子集名称
getChildrenApi?: Function // 子集获取api
}
export default defineComponent({
name: 'c-card',
emits: ['change'],
props: {
idName: {
type: String,
default: '',
},
// 卡片标题字段
codeName: {
type: String,
default: '',
},
codeNameLink: {
type: Boolean,
default: false,
},
subCodeName: {
type: String,
default: '',
},
// 卡片数据
cardData: {
type: Array,
default: () => [] as any[],
required: true,
},
// 卡片显示字段
/**
* {
* label: label
* name: 对应字段名称
* }
*/
attributesList: {
type: Array as PropType<Attributes[]>,
default: [] as Attributes[],
required: true,
},
// 是否需要显示 status
needStatusTag: {
type: Boolean,
default: true,
},
// label 宽度 默认 200rpx
labelWidth: {
type: Number,
default: 200,
},
// 是否存在 footer 插件
hasFooter: {
type: Boolean,
default: false,
},
hasAction: {
type: Boolean,
default: true,
},
needNoData: {
type: Boolean,
default: true,
},
customStatus: {
type: Boolean,
default: false,
},
codeFormat: {
type: Function,
default: (value: any) => value,
},
createDateName: {
type: String,
default: '',
},
// 显示创建时间
showCreateDate: {
type: Boolean,
default: true,
},
isChildren: {
type: Boolean,
default: false,
},
},
methods: {
onChange(type: 'actionClick' | 'click' | 'link', item: any) {
this.$emit('change', { type, item })
},
ActionClickHandle(item: any, index: number) {
// 触发点击 action 按钮事件
this.$emit('actionClick', item)
this.$emit('index', index)
this.onChange('actionClick', item)
},
contentClickHandle(item: any, index: number) {
// 触发点击 click 按钮事件
this.$emit('click', item)
this.$emit('index', index)
this.onChange('click', item)
},
formatDate(date: string) {
// 日期格式化
return formatDate(date)
},
stopPropagation(event: any) {
// 阻止冒泡
event.stopPropagation()
},
showToast(title: string) {
// 显示过长文本完整内容
;(this.$refs.uToast as any).show({
title,
icon: false,
})
},
// 打开显示文件
downloadFile(url: string) {
openFile(url, this)
},
// 打开显示图片
showImage(url: string) {
if (url) {
uni.previewImage({ urls: [url] })
}
},
// 多字段拼接处理函数
multiFieldHandle(
item: any,
names: string[],
separator: string | undefined
) {
let str = ''
for (let i = 0; i < names.length; i++) {
if (i !== names.length - 1) {
let nameItem = names[i] as any
if (typeof nameItem === 'string') {
str +=
item[nameItem] +
(separator != null ? separator : ' ')
} else if (
typeof nameItem === 'object' &&
nameItem != null
) {
// 进行格式化处理
let str2 = ''
if (
nameItem.name &&
nameItem.format &&
nameItem.formatFunc
) {
str2 =
nameItem.formatFunc(item[nameItem.name]) +
(separator != null ? separator : ' ')
}
str += str2
}
} else {
str += item[names[i]]
}
}
return str
},
childrenClick() {
this.$emit('click')
},
closeChildrensData() {
this.titleList.length = 0
this.showChildrens = false
this.$emit('overflowScrollingAuto', false)
},
codeNameClick(item: any) {
if (this.codeNameLink) {
this.onChange('link', item)
} else {
this.showToast(
this.codeName === ''
? ''
: (this.codeFormat as Function)(item[this.codeName])
)
}
},
change(index: number) {
this.current = index
let title = this.titleList[index]
this.getChildrenData(title.api, title.item)
},
getChildrenData(api: Function, item: any) {
if (this.isChildren) {
;(this.childrenGetData as Function).call(this, api, item)
} else {
if (!this.showChildrens) {
this.showChildrens = true
}
this.cardChildrenData.length = 0
uni.showLoading({
title: 'Loading ...',
})
api(item).then((data: any) => {
uni.hideLoading()
if (data.Result == 1) {
this.cardChildrenData.push(...data.Data)
this.titleListHandle(api, item)
}
})
this.$emit('overflowScrollingAuto', true)
}
},
titleListHandle(api: Function, item: any) {
if (this.titleList.length > 0) {
for (let [index, title] of this.titleList.entries()) {
if (title.name === item[this.codeName]) {
this.current = index
this.titleList.splice(
index + 1,
this.titleList.length - 1
)
} else {
this.titleList.push({
name: item[this.codeName],
api,
item,
})
this.current = this.titleList.length - 1
}
}
} else {
this.titleList.push({
name: item[this.codeName],
api,
item,
})
this.current = this.titleList.length - 1
}
},
},
provide() {
return {
getChildrenData: this.getChildrenData,
}
},
setup(props) {
let childrenGetData = props.isChildren
? inject('getChildrenData')
: () => {}
let cardChildrenData = reactive([] as any[])
let showChildrens = ref(false)
let current = ref(0)
let titleList = reactive(
[] as {
name: string
api: Function
item: any
}[]
)
return {
childrenGetData,
cardChildrenData,
showChildrens,
titleList,
current,
}
},
})
</script>
<style lang="scss" scoped>
.card-content {
.no-data {
width: 100%;
position: fixed;
top: 40vh;
text-align: center;
}
.children-extends-btn {
display: block;
width: 50rpx;
height: 50rpx;
line-height: 50rpx;
text-align: center;
border-radius: 10rpx;
margin: 5rpx;
background-color: $laxton-type-active;
}
.card-childrens-popup {
transform: translateZ(0);
.card-childrens {
padding-bottom: 10rpx;
}
.childrens-header {
display: flex;
justify-content: space-between;
height: 100rpx;
padding: 0 30rpx;
line-height: 100rpx;
}
}
.card-item {
background-color: #fff;
border-radius: 15rpx;
margin: 30rpx;
box-shadow: 0rpx 5rpx 10rpx #ececec;
padding-top: 15rpx;
flex-direction: column;
view {
display: flex;
}
.file {
text-decoration: none;
color: $laxton-type-active;
:active {
color: $laxton-type-active;
}
:visited {
color: $laxton-type-active;
}
}
> :first-child {
justify-content: space-between;
height: 70rpx;
line-height: 70rpx;
border-bottom: 1px #ececec solid;
> :first-child {
font-size: 28rpx;
font-weight: bold;
}
}
.title {
padding: 0 30rpx;
> span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
width: 0;
}
> span:last-child {
width: 150rpx;
text-align: right;
}
}
.card-data {
display: flex;
flex-direction: column;
padding: 0 30rpx;
.card-attribute {
display: flex;
border-top: 1px #ececec solid;
align-items: center;
padding: 10rpx 0;
> :first-child {
padding-right: 20rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> :last-child {
flex: 1;
width: 0;
white-space: normal;
word-break: break-all;
}
}
> :first-child {
border-top: none;
}
}
.footer {
.default {
justify-content: space-between;
border-top: 1px #ececec solid;
align-items: center;
width: 100%;
.u-size-default {
height: 60rpx;
}
padding: 20rpx 30rpx 30rpx 30rpx;
}
}
}
}
</style>
<!-- c-scroll-view-back-top -->
<template>
<view>
<c-back-top :scrollTop="old.scrollTop" @click="back"> </c-back-top>
<scroll-view
:scroll-y="scrollY"
:scroll-x="scrollX"
:scroll-top="scrollTop"
:scroll-with-animation="true"
@scroll="scroll"
@scrolltolower="scrolltolower"
:class="[
'c-scroll-view-back-top',
overflowScrollingAuto ? 'overflow-scrolling' : '',
]"
:style="{
height: height ? height : '100vh',
}"
>
<view>
<slot name="content"></slot>
</view>
</scroll-view>
</view>
</template>
<script lang="ts">
import { defineComponent, reactive } from '@vue/composition-api'
export default defineComponent({
props: {
scrollY: {
type: Boolean,
default: true,
},
scrollX: {
type: Boolean,
default: true,
},
scrollWithAnimation: {
type: Boolean,
default: true,
},
height: {
type: [String, Number],
},
overflowScrollingAuto: {
type: Boolean,
default: false,
},
},
methods: {
scroll(e: any) {
this.old.scrollTop = e.detail.scrollTop
},
back() {
this.scrollTop = this.old.scrollTop
this.$nextTick(() => {
this.scrollTop = 0
})
},
scrolltolower(event: any) {
this.$emit('scrolltolower', event)
},
},
setup() {
return reactive({
scrollTop: 0,
old: {
scrollTop: 0,
},
})
},
})
</script>
<style lang="scss" scoped>
.c-scroll-view-back-top {
width: 750rpx;
background-color: #f3f4f6;
}
.overflow-scrolling {
::v-deep .uni-scroll-view {
-webkit-overflow-scrolling: auto;
}
}
</style>
<!-- c-back-top -->
<template>
<view>
<c-back-top :scrollTop="old.scrollTop" @click="back"> </c-back-top>
<scroll-view
:scroll-y="scrollY"
:scroll-x="scrollX"
:scroll-top="scrollTop"
:scroll-with-animation="true"
@scroll="scroll"
@scrolltolower="scrolltolower"
:class="[
'c-scroll-view-back-top',
overflowScrollingAuto ? 'overflow-scrolling' : '',
]"
:style="{
height: height ? height : '100vh',
}"
>
<view>
<slot name="content"></slot>
</view>
</scroll-view>
</view>
</template>
<script lang="ts">
import { defineComponent, reactive } from '@vue/composition-api'
export default defineComponent({
props: {
scrollY: {
type: Boolean,
default: true,
},
scrollX: {
type: Boolean,
default: true,
},
scrollWithAnimation: {
type: Boolean,
default: true,
},
height: {
type: [String, Number],
},
overflowScrollingAuto: {
type: Boolean,
default: false,
},
},
methods: {
scroll(e: any) {
this.old.scrollTop = e.detail.scrollTop
},
back() {
this.scrollTop = this.old.scrollTop
this.$nextTick(() => {
this.scrollTop = 0
})
},
scrolltolower(event: any) {
this.$emit('scrolltolower', event)
},
},
setup() {
return reactive({
scrollTop: 0,
old: {
scrollTop: 0,
},
})
},
})
</script>
<style lang="scss" scoped>
.c-scroll-view-back-top {
width: 750rpx;
background-color: #f3f4f6;
}
.overflow-scrolling {
::v-deep .uni-scroll-view {
-webkit-overflow-scrolling: auto;
}
}
</style>