{{wikiTitle}}
添加组件说明
复制链接
编辑文档
CRMEB PRO 添加组件说明
一、概述
CRMEB PRO项目包含两套前端系统:后台管理端(Vue CLI)和移动端(uni-app),本文档详细说明如何在两端开发和集成新组件。
1.1 组件目录结构
# 后台管理端
view/admin/src/
├── components/ # 全局公共组件
│ ├── cards/ # 卡片组件
│ ├── charts/ # 图表组件
│ ├── common/ # 通用组件
│ └── ...
└── views/ # 页面
└── [module]/
└── components/ # 页面私有组件
# 移动端
view/uniapp/
├── components/ # 全局公共组件
│ ├── Loading/ # 加载组件
│ ├── WaterfallsFlow/ # 瀑布流组件
│ └── ...
├── uni_modules/ # uni-app插件市场组件
└── pages/
└── [module]/
└── components/ # 页面私有组件
二、后台管理端组件开发
2.1 创建基础组件
2.1.1 组件文件结构
src/components/
└── DataCard/
├── index.vue # 主组件
├── style.scss # 样式文件
└── index.js # 导出文件(可选)
2.1.2 组件代码示例
src/components/DataCard/index.vue:
<template>
<el-card class="data-card" :class="{ 'is-loading': loading }">
<!-- 卡片头部 -->
<div class="card-header" slot="header">
<div class="header-left">
<i :class="icon" v-if="icon"></i>
<span class="title">{{ title }}</span>
</div>
<div class="header-right">
<slot name="header-extra"></slot>
</div>
</div>
<!-- 卡片内容 -->
<div class="card-body">
<!-- 加载状态 -->
<div class="loading-mask" v-if="loading">
<i class="el-icon-loading"></i>
</div>
<!-- 主要数据 -->
<div class="main-value">
<span class="value" :style="{ color: valueColor }">
{{ formatValue(value) }}
</span>
<span class="unit" v-if="unit">{{ unit }}</span>
</div>
<!-- 副标题/描述 -->
<div class="sub-info" v-if="subTitle">
{{ subTitle }}
</div>
<!-- 趋势指示 -->
<div class="trend" v-if="showTrend">
<span
class="trend-value"
:class="trendClass"
>
<i :class="trendIcon"></i>
{{ trendValue }}%
</span>
<span class="trend-label">{{ trendLabel }}</span>
</div>
<!-- 自定义内容插槽 -->
<slot></slot>
</div>
<!-- 卡片底部 -->
<div class="card-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</el-card>
</template>
<script>
export default {
name: 'DataCard',
props: {
// 卡片标题
title: {
type: String,
required: true
},
// 图标类名
icon: {
type: String,
default: ''
},
// 主要数值
value: {
type: [Number, String],
default: 0
},
// 单位
unit: {
type: String,
default: ''
},
// 数值颜色
valueColor: {
type: String,
default: '#303133'
},
// 副标题
subTitle: {
type: String,
default: ''
},
// 是否显示趋势
showTrend: {
type: Boolean,
default: false
},
// 趋势数值
trendValue: {
type: Number,
default: 0
},
// 趋势说明
trendLabel: {
type: String,
default: '较上期'
},
// 数值格式化类型
formatType: {
type: String,
default: 'number', // number | currency | percent
validator: (val) => ['number', 'currency', 'percent'].includes(val)
},
// 加载状态
loading: {
type: Boolean,
default: false
}
},
computed: {
// 趋势样式类
trendClass() {
if (this.trendValue > 0) return 'trend-up';
if (this.trendValue < 0) return 'trend-down';
return 'trend-flat';
},
// 趋势图标
trendIcon() {
if (this.trendValue > 0) return 'el-icon-caret-top';
if (this.trendValue < 0) return 'el-icon-caret-bottom';
return 'el-icon-minus';
}
},
methods: {
// 格式化数值
formatValue(val) {
if (val === null || val === undefined) return '--';
const num = Number(val);
if (isNaN(num)) return val;
switch (this.formatType) {
case 'currency':
return '¥' + num.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
case 'percent':
return (num * 100).toFixed(2) + '%';
default:
return num.toLocaleString('zh-CN');
}
}
}
};
</script>
<style lang="scss" scoped>
.data-card {
position: relative;
&.is-loading {
.card-body {
opacity: 0.5;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
display: flex;
align-items: center;
i {
margin-right: 8px;
font-size: 18px;
color: #409EFF;
}
.title {
font-size: 14px;
color: #606266;
}
}
}
.card-body {
position: relative;
.loading-mask {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
color: #409EFF;
}
.main-value {
margin-bottom: 8px;
.value {
font-size: 28px;
font-weight: 600;
}
.unit {
margin-left: 4px;
font-size: 14px;
color: #909399;
}
}
.sub-info {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.trend {
display: flex;
align-items: center;
font-size: 12px;
.trend-value {
margin-right: 8px;
&.trend-up {
color: #F56C6C;
}
&.trend-down {
color: #67C23A;
}
&.trend-flat {
color: #909399;
}
}
.trend-label {
color: #909399;
}
}
}
.card-footer {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #EBEEF5;
}
}
</style>
2.2 组件注册
2.2.1 局部注册
<script>
import DataCard from '@/components/DataCard';
export default {
components: {
DataCard
}
};
</script>
2.2.2 全局注册
src/components/index.js:
import Vue from 'vue';
import DataCard from './DataCard';
import StatusTag from './StatusTag';
import ImageUpload from './ImageUpload';
// 组件列表
const components = {
DataCard,
StatusTag,
ImageUpload
};
// 批量注册全局组件
Object.keys(components).forEach(key => {
Vue.component(key, components[key]);
});
export default components;
src/main.js:
import '@/components';
2.3 组件通信
2.3.1 Props传递
<!-- 父组件 -->
<DataCard
title="今日订单"
:value="orderCount"
unit="单"
:loading="loading"
@click="handleCardClick"
/>
<!-- 子组件 -->
<script>
export default {
props: {
value: {
type: Number,
required: true,
validator: (val) => val >= 0
}
}
};
</script>
2.3.2 事件传递
<!-- 子组件 -->
<script>
export default {
methods: {
handleClick() {
this.$emit('click', this.value);
this.$emit('update:value', newValue); // .sync修饰符
}
}
};
</script>
<!-- 父组件 -->
<DataCard
:value.sync="orderCount"
@click="handleClick"
/>
2.3.3 插槽使用
<!-- 子组件定义插槽 -->
<template>
<div class="card">
<!-- 默认插槽 -->
<slot></slot>
<!-- 具名插槽 -->
<slot name="header"></slot>
<!-- 作用域插槽 -->
<slot name="item" :data="itemData" :index="index"></slot>
</div>
</template>
<!-- 父组件使用 -->
<DataCard>
<!-- 默认插槽内容 -->
<p>默认内容</p>
<!-- 具名插槽 -->
<template #header>
<h3>标题</h3>
</template>
<!-- 作用域插槽 -->
<template #item="{ data, index }">
<div>{{ index }}: {{ data.name }}</div>
</template>
</DataCard>
三、移动端组件开发
3.1 创建基础组件
3.1.1 组件文件结构
components/
└── ProductCard/
└── index.vue
3.1.2 组件代码示例
components/ProductCard/index.vue:
<template>
<view
class="product-card"
:class="[`size-${size}`, { 'is-disabled': disabled }]"
@click="handleClick"
>
<!-- 商品图片 -->
<view class="card-image">
<image
:src="product.image"
:mode="imageMode"
:lazy-load="lazyLoad"
@load="onImageLoad"
@error="onImageError"
/>
<!-- 角标 -->
<view class="badge" v-if="badge">
{{ badge }}
</view>
<!-- 售罄遮罩 -->
<view class="sold-out-mask" v-if="product.stock <= 0">
<text>已售罄</text>
</view>
</view>
<!-- 商品信息 -->
<view class="card-info">
<!-- 商品名称 -->
<view class="product-name">
<text class="name-text">{{ product.store_name }}</text>
</view>
<!-- 商品描述 -->
<view class="product-desc" v-if="showDesc && product.store_info">
<text>{{ product.store_info }}</text>
</view>
<!-- 价格区域 -->
<view class="price-area">
<view class="current-price">
<text class="currency">¥</text>
<text class="price-int">{{ priceInt }}</text>
<text class="price-decimal" v-if="priceDecimal">.{{ priceDecimal }}</text>
</view>
<view class="original-price" v-if="showOriginal && product.ot_price > product.price">
<text>¥{{ product.ot_price }}</text>
</view>
</view>
<!-- 销量/标签 -->
<view class="extra-info">
<text class="sales" v-if="showSales">已售{{ formatSales(product.sales) }}</text>
<slot name="extra"></slot>
</view>
</view>
<!-- 操作按钮插槽 -->
<view class="card-action" v-if="$slots.action">
<slot name="action"></slot>
</view>
</view>
</template>
<script>
export default {
name: 'ProductCard',
props: {
// 商品数据
product: {
type: Object,
required: true,
default: () => ({})
},
// 卡片尺寸
size: {
type: String,
default: 'medium', // small | medium | large
validator: (val) => ['small', 'medium', 'large'].includes(val)
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否显示描述
showDesc: {
type: Boolean,
default: false
},
// 是否显示原价
showOriginal: {
type: Boolean,
default: true
},
// 是否显示销量
showSales: {
type: Boolean,
default: true
},
// 角标文字
badge: {
type: String,
default: ''
},
// 图片裁剪模式
imageMode: {
type: String,
default: 'aspectFill'
},
// 是否懒加载
lazyLoad: {
type: Boolean,
default: true
}
},
computed: {
// 价格整数部分
priceInt() {
const price = Number(this.product.price) || 0;
return Math.floor(price);
},
// 价格小数部分
priceDecimal() {
const price = Number(this.product.price) || 0;
const decimal = (price % 1).toFixed(2).substring(2);
return decimal !== '00' ? decimal : '';
}
},
methods: {
// 点击事件
handleClick() {
if (this.disabled) return;
this.$emit('click', this.product);
},
// 图片加载完成
onImageLoad(e) {
this.$emit('image-load', e);
},
// 图片加载失败
onImageError(e) {
this.$emit('image-error', e);
},
// 格式化销量
formatSales(sales) {
const num = Number(sales) || 0;
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
}
return num;
},
// 跳转商品详情
goDetail() {
const id = this.product.id;
uni.navigateTo({
url: `/pages/goods_details/index?id=${id}`
});
}
}
};
</script>
<style lang="scss" scoped>
.product-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
// 尺寸变体
&.size-small {
.card-image image {
height: 180rpx;
}
.product-name {
font-size: 24rpx;
}
}
&.size-medium {
.card-image image {
height: 280rpx;
}
}
&.size-large {
.card-image image {
height: 400rpx;
}
}
&.is-disabled {
opacity: 0.6;
}
.card-image {
position: relative;
width: 100%;
image {
width: 100%;
display: block;
}
.badge {
position: absolute;
top: 16rpx;
left: 16rpx;
padding: 4rpx 12rpx;
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
color: #fff;
font-size: 22rpx;
border-radius: 8rpx;
}
.sold-out-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
text {
color: #fff;
font-size: 28rpx;
}
}
}
.card-info {
padding: 16rpx 20rpx;
.product-name {
margin-bottom: 8rpx;
.name-text {
font-size: 28rpx;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
}
.product-desc {
margin-bottom: 8rpx;
font-size: 24rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-area {
display: flex;
align-items: baseline;
margin-bottom: 8rpx;
.current-price {
color: #e93323;
.currency {
font-size: 24rpx;
}
.price-int {
font-size: 36rpx;
font-weight: 600;
}
.price-decimal {
font-size: 24rpx;
}
}
.original-price {
margin-left: 12rpx;
font-size: 24rpx;
color: #999;
text-decoration: line-through;
}
}
.extra-info {
display: flex;
align-items: center;
.sales {
font-size: 22rpx;
color: #999;
}
}
}
.card-action {
padding: 0 20rpx 20rpx;
}
}
</style>
3.2 组件注册
3.2.1 局部注册
<script>
import ProductCard from '@/components/ProductCard/index.vue';
export default {
components: {
ProductCard
}
};
</script>
3.2.2 easycom自动注册
在 pages.json 中配置:
{
"easycom": {
"autoscan": true,
"custom": {
"^c-(.*)": "@/components/c-$1/index.vue",
"^ProductCard": "@/components/ProductCard/index.vue"
}
}
}
配置后组件可直接使用,无需导入:
<template>
<ProductCard :product="item" />
</template>
3.2.3 全局注册
main.js:
import Vue from 'vue';
import ProductCard from '@/components/ProductCard/index.vue';
import EmptyPage from '@/components/emptyPage.vue';
Vue.component('ProductCard', ProductCard);
Vue.component('EmptyPage', EmptyPage);
3.3 uni_modules 插件开发
3.3.1 插件目录结构
uni_modules/
└── my-component/
├── components/
│ └── my-component/
│ └── my-component.vue
├── package.json
└── readme.md
3.3.2 package.json
{
"id": "my-component",
"displayName": "我的组件",
"version": "1.0.0",
"description": "自定义组件",
"keywords": ["component"],
"dcloudext": {
"type": "component-vue"
}
}
四、组件设计规范
4.1 命名规范
// 组件名使用PascalCase
export default {
name: 'DataCard' // 组件名
};
// 文件名使用kebab-case或PascalCase
// data-card.vue 或 DataCard.vue
// Props使用camelCase
props: {
maxLength: Number,
showIcon: Boolean
}
// 事件名使用kebab-case
this.$emit('update-value');
this.$emit('item-click');
4.2 Props设计
props: {
// 基础类型验证
title: String,
count: Number,
isActive: Boolean,
list: Array,
config: Object,
callback: Function,
// 必填项
id: {
type: [String, Number],
required: true
},
// 默认值
size: {
type: String,
default: 'medium'
},
// 对象/数组默认值
options: {
type: Object,
default: () => ({})
},
// 自定义验证
status: {
type: String,
validator: (val) => ['pending', 'success', 'error'].includes(val)
}
}
4.3 组件文档
<script>
/**
* 数据卡片组件
* @description 用于展示统计数据的卡片组件
* @example
* <DataCard
* title="今日订单"
* :value="100"
* unit="单"
* @click="handleClick"
* />
*/
export default {
name: 'DataCard',
props: {
/**
* 卡片标题
*/
title: {
type: String,
required: true
},
/**
* 数值
*/
value: {
type: [Number, String],
default: 0
}
},
/**
* 点击事件
* @event click
* @param {Object} data - 卡片数据
*/
methods: {
handleClick() {
this.$emit('click', { title: this.title, value: this.value });
}
}
};
</script>
五、高级组件模式
5.1 函数式组件
<template functional>
<div class="icon-wrapper">
<i :class="['icon', `icon-${props.name}`]" :style="{ fontSize: props.size }"></i>
</div>
</template>
<script>
export default {
name: 'Icon',
functional: true,
props: {
name: String,
size: {
type: String,
default: '24px'
}
}
};
</script>
5.2 渲染函数组件
export default {
name: 'DynamicTag',
props: {
tag: {
type: String,
default: 'div'
},
level: Number
},
render(h) {
const tag = this.level ? `h${this.level}` : this.tag;
return h(tag, {
class: 'dynamic-tag',
on: {
click: () => this.$emit('click')
}
}, this.$slots.default);
}
};
5.3 高阶组件(HOC)
// withLoading.js - 添加加载状态的高阶组件
export default function withLoading(WrappedComponent) {
return {
name: `WithLoading${WrappedComponent.name}`,
props: {
...WrappedComponent.props,
loading: Boolean
},
render(h) {
const children = [
h(WrappedComponent, {
props: this.$props,
on: this.$listeners,
scopedSlots: this.$scopedSlots
})
];
if (this.loading) {
children.push(h('div', { class: 'loading-overlay' }, [
h('i', { class: 'el-icon-loading' })
]));
}
return h('div', { class: 'with-loading-wrapper' }, children);
}
};
}
// 使用
import DataCard from './DataCard';
import withLoading from './withLoading';
export default withLoading(DataCard);
5.4 混入(Mixin)
// mixins/pagination.js
export default {
data() {
return {
page: 1,
limit: 10,
total: 0,
loading: false,
list: []
};
},
computed: {
hasMore() {
return this.list.length < this.total;
}
},
methods: {
async loadData() {
if (this.loading) return;
this.loading = true;
try {
const res = await this.fetchData({
page: this.page,
limit: this.limit
});
this.list = res.data.list;
this.total = res.data.total;
} finally {
this.loading = false;
}
},
async loadMore() {
if (!this.hasMore || this.loading) return;
this.page++;
this.loading = true;
try {
const res = await this.fetchData({
page: this.page,
limit: this.limit
});
this.list = [...this.list, ...res.data.list];
} finally {
this.loading = false;
}
},
refresh() {
this.page = 1;
this.list = [];
this.loadData();
},
// 需要在使用的组件中实现
fetchData() {
throw new Error('fetchData method must be implemented');
}
}
};
// 使用
import paginationMixin from '@/mixins/pagination';
export default {
mixins: [paginationMixin],
methods: {
fetchData(params) {
return getProductList(params);
}
}
};
六、组件测试
6.1 单元测试
// tests/unit/DataCard.spec.js
import { shallowMount } from '@vue/test-utils';
import DataCard from '@/components/DataCard';
describe('DataCard.vue', () => {
it('renders title prop', () => {
const title = '测试标题';
const wrapper = shallowMount(DataCard, {
propsData: { title }
});
expect(wrapper.text()).toContain(title);
});
it('formats currency value correctly', () => {
const wrapper = shallowMount(DataCard, {
propsData: {
title: '销售额',
value: 1234.56,
formatType: 'currency'
}
});
expect(wrapper.find('.value').text()).toBe('¥1,234.56');
});
it('emits click event', async () => {
const wrapper = shallowMount(DataCard, {
propsData: { title: '测试' }
});
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
});
七、组件开发清单
7.1 开发检查清单
- [ ] 组件命名符合规范
- [ ] Props定义完整(类型、默认值、验证)
- [ ] 事件命名使用kebab-case
- [ ] 插槽设计合理
- [ ] 样式使用scoped
- [ ] 支持主题定制(CSS变量)
- [ ] 编写组件文档
- [ ] 编写单元测试
- [ ] 多端兼容(uni-app)
7.2 常用组件类型
| 类型 | 示例 | 说明 |
|---|---|---|
| 基础组件 | Button, Icon, Tag | 最小功能单元 |
| 表单组件 | Input, Select, DatePicker | 数据输入 |
| 数据展示 | Table, Card, List | 数据展示 |
| 反馈组件 | Modal, Toast, Loading | 用户反馈 |
| 导航组件 | Menu, Tabs, Pagination | 页面导航 |
| 业务组件 | ProductCard, OrderItem | 特定业务 |
注意事项
- 组件命名规范:组件文件名使用 PascalCase(如
DataCard.vue),使用时用 kebab-case(如<data-card />) - Props 类型定义:必须为所有 Props 定义类型和默认值,复杂类型使用工厂函数返回默认值
- 事件命名:自定义事件名使用 kebab-case 格式(如
@item-click),避免与原生事件冲突 - 样式隔离:使用
scoped样式或 CSS Modules 避免样式污染,深度选择器用::v-deep或/deep/ - uni-app 兼容:移动端组件需要考虑多端兼容性,避免使用 Web 专属 API
- 性能优化:大数据量组件使用虚拟滚动,避免频繁触发重渲染
- 可访问性:为交互元素添加
aria-*属性,支持键盘操作 - 全局组件谨慎注册:只有高频使用的基础组件才注册为全局组件,业务组件按需引入
常见问题
Q: 子组件无法触发父组件的事件?
A: 检查事件名是否正确(注意 kebab-case),确保使用$emit正确触发,父组件使用@事件名监听Q: Props 数据更新后组件未响应?
A: Props 是单向数据流,如果是对象/数组类型,检查是否直接修改了引用内部的值。使用watch监听或使用计算属性Q: 组件样式在 uni-app 中不生效?
A: uni-app 对部分 CSS 选择器支持有限,避免使用*选择器,复杂选择器可能不生效,建议使用 class 选择器Q: 如何在组件中访问全局状态?
A: 使用 Vuex 的mapState、mapGetters辅助函数,或通过this.$store直接访问Q: 组件销毁后定时器还在运行?
A: 在beforeDestroy(Vue2)或onUnmounted(Vue3)生命周期中清除定时器和事件监听Q: 如何实现组件的按需加载?
A: 使用动态导入() => import('@/components/XxxComponent.vue')配合路由懒加载或异步组件
评论({{cateWiki.comment_num}})
最新
最早
{{cateWiki.page_view_num}}人看过该文档
评论(0)
最新
最早
105人看过
登录/注册
即可发表评论
{{item.user ? item.user.nickname : ''}}
(自评)
{{item.content}}
搜索结果
为您找到{{wikiCount}}条结果
{{item.page_view_num}}
{{item.like ? item.like.like_num : 0}}
{{item.comment ? item.comment.comment_num : 0}}
位置:
{{path.name}}
{{(i+1) == getCataloguePathData(item.catalogue).length ? '':'/'}}
