6.1 自定义组件基础
6.1.1 组件创建与注册
在 UniApp 中,组件是构建用户界面的基本单元。通过创建自定义组件,我们可以实现代码复用、提高开发效率。
<!-- components/MyButton/MyButton.vue -->
<template>
<button
class="my-button"
:class="[
`my-button--${type}`,
`my-button--${size}`,
{
'my-button--disabled': disabled,
'my-button--loading': loading,
'my-button--round': round,
'my-button--plain': plain
}
]"
:disabled="disabled || loading"
@click="handleClick"
>
<!-- 加载状态 -->
<view v-if="loading" class="my-button__loading">
<view class="loading-spinner"></view>
</view>
<!-- 图标 -->
<view v-if="icon && !loading" class="my-button__icon">
<text class="iconfont" :class="icon"></text>
</view>
<!-- 文本内容 -->
<view v-if="$slots.default" class="my-button__text">
<slot></slot>
</view>
</button>
</template>
<script>
export default {
name: 'MyButton',
props: {
// 按钮类型
type: {
type: String,
default: 'default',
validator: (value) => {
return ['default', 'primary', 'success', 'warning', 'danger', 'info'].includes(value);
}
},
// 按钮尺寸
size: {
type: String,
default: 'normal',
validator: (value) => {
return ['mini', 'small', 'normal', 'large'].includes(value);
}
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否加载中
loading: {
type: Boolean,
default: false
},
// 是否圆角
round: {
type: Boolean,
default: false
},
// 是否朴素按钮
plain: {
type: Boolean,
default: false
},
// 图标
icon: {
type: String,
default: ''
},
// 防抖延迟
debounce: {
type: Number,
default: 300
}
},
data() {
return {
clickTimer: null
};
},
methods: {
handleClick(event) {
if (this.disabled || this.loading) {
return;
}
// 防抖处理
if (this.clickTimer) {
clearTimeout(this.clickTimer);
}
this.clickTimer = setTimeout(() => {
this.$emit('click', event);
this.clickTimer = null;
}, this.debounce);
}
},
beforeDestroy() {
if (this.clickTimer) {
clearTimeout(this.clickTimer);
}
}
};
</script>
<style scoped>
.my-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0 16px;
height: 44px;
font-size: 16px;
line-height: 1.2;
text-align: center;
border-radius: 4px;
border: 1px solid transparent;
background-color: #fff;
color: #323233;
cursor: pointer;
transition: all 0.3s;
user-select: none;
-webkit-appearance: none;
-webkit-text-size-adjust: 100%;
}
.my-button:active {
opacity: 0.7;
}
/* 按钮类型样式 */
.my-button--primary {
background-color: #1989fa;
border-color: #1989fa;
color: #fff;
}
.my-button--success {
background-color: #07c160;
border-color: #07c160;
color: #fff;
}
.my-button--warning {
background-color: #ff976a;
border-color: #ff976a;
color: #fff;
}
.my-button--danger {
background-color: #ee0a24;
border-color: #ee0a24;
color: #fff;
}
.my-button--info {
background-color: #909399;
border-color: #909399;
color: #fff;
}
/* 朴素按钮样式 */
.my-button--plain {
background-color: #fff;
}
.my-button--plain.my-button--primary {
color: #1989fa;
border-color: #1989fa;
}
.my-button--plain.my-button--success {
color: #07c160;
border-color: #07c160;
}
.my-button--plain.my-button--warning {
color: #ff976a;
border-color: #ff976a;
}
.my-button--plain.my-button--danger {
color: #ee0a24;
border-color: #ee0a24;
}
.my-button--plain.my-button--info {
color: #909399;
border-color: #909399;
}
/* 按钮尺寸样式 */
.my-button--mini {
height: 24px;
padding: 0 8px;
font-size: 12px;
}
.my-button--small {
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.my-button--large {
height: 50px;
padding: 0 20px;
font-size: 18px;
}
/* 圆角按钮 */
.my-button--round {
border-radius: 22px;
}
.my-button--round.my-button--mini {
border-radius: 12px;
}
.my-button--round.my-button--small {
border-radius: 16px;
}
.my-button--round.my-button--large {
border-radius: 25px;
}
/* 禁用状态 */
.my-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.my-button--disabled:active {
opacity: 0.5;
}
/* 加载状态 */
.my-button--loading {
cursor: default;
}
.my-button__loading {
margin-right: 8px;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.my-button__icon {
margin-right: 8px;
font-size: 1.2em;
}
.my-button__text {
flex: 1;
}
</style>
6.4 组件库开发
6.4.1 组件库架构设计
创建一个完整的 UniApp 组件库需要考虑以下几个方面:
my-ui/
├── packages/ # 组件源码
│ ├── button/ # 按钮组件
│ │ ├── index.vue
│ │ ├── index.js
│ │ └── README.md
│ ├── input/ # 输入框组件
│ ├── modal/ # 模态框组件
│ └── index.js # 组件库入口
├── examples/ # 示例项目
│ ├── pages/
│ ├── static/
│ └── main.js
├── docs/ # 文档
├── build/ # 构建脚本
├── tests/ # 测试文件
├── package.json
└── README.md
组件库入口文件
// packages/index.js
import Button from './button';
import Input from './input';
import Modal from './modal';
import Toast from './toast';
import Loading from './loading';
import Picker from './picker';
import DatePicker from './date-picker';
import ActionSheet from './action-sheet';
import Popup from './popup';
import Overlay from './overlay';
const components = {
Button,
Input,
Modal,
Toast,
Loading,
Picker,
DatePicker,
ActionSheet,
Popup,
Overlay
};
// 定义 install 方法,接收 Vue 作为参数
const install = function(Vue) {
// 判断是否安装
if (install.installed) return;
install.installed = true;
// 遍历注册全局组件
Object.keys(components).forEach(key => {
Vue.component(components[key].name, components[key]);
});
};
// 判断是否是直接引入文件
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue);
}
export default {
version: '1.0.0',
install,
...components
};
export {
Button,
Input,
Modal,
Toast,
Loading,
Picker,
DatePicker,
ActionSheet,
Popup,
Overlay
};
6.4.2 通用组件开发
Toast 提示组件
<!-- packages/toast/index.vue -->
<template>
<view v-if="visible" class="my-toast" :class="toastClass" @click="handleClick">
<view class="my-toast__content">
<view v-if="icon" class="my-toast__icon">
<text class="iconfont" :class="iconClass"></text>
</view>
<text class="my-toast__text">{{ message }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'MyToast',
props: {
message: {
type: String,
default: ''
},
type: {
type: String,
default: 'default',
validator: (value) => {
return ['default', 'success', 'error', 'warning', 'loading'].includes(value);
}
},
position: {
type: String,
default: 'center',
validator: (value) => {
return ['top', 'center', 'bottom'].includes(value);
}
},
duration: {
type: Number,
default: 2000
},
mask: {
type: Boolean,
default: false
},
icon: {
type: Boolean,
default: true
}
},
data() {
return {
visible: false,
timer: null
};
},
computed: {
toastClass() {
return [
`my-toast--${this.type}`,
`my-toast--${this.position}`,
{
'my-toast--mask': this.mask
}
];
},
iconClass() {
const iconMap = {
success: 'icon-check-circle',
error: 'icon-close-circle',
warning: 'icon-warning-circle',
loading: 'icon-loading'
};
return iconMap[this.type];
}
},
methods: {
show() {
this.visible = true;
if (this.duration > 0 && this.type !== 'loading') {
this.timer = setTimeout(() => {
this.hide();
}, this.duration);
}
},
hide() {
this.visible = false;
this.clearTimer();
this.$emit('close');
},
handleClick() {
if (!this.mask) {
this.hide();
}
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
},
beforeDestroy() {
this.clearTimer();
}
};
</script>
<style scoped>
.my-toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
max-width: 80%;
min-width: 120px;
pointer-events: none;
}
.my-toast--top {
top: 20%;
}
.my-toast--center {
top: 50%;
transform: translate(-50%, -50%);
}
.my-toast--bottom {
bottom: 20%;
}
.my-toast--mask {
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
transform: none;
pointer-events: auto;
}
.my-toast__content {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 20px;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
word-break: break-all;
}
.my-toast--success .my-toast__content {
background-color: rgba(7, 193, 96, 0.9);
}
.my-toast--error .my-toast__content {
background-color: rgba(238, 10, 36, 0.9);
}
.my-toast--warning .my-toast__content {
background-color: rgba(255, 151, 106, 0.9);
}
.my-toast__icon {
margin-bottom: 8px;
font-size: 24px;
}
.my-toast__icon .icon-loading {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.my-toast__text {
text-align: center;
}
</style>
Toast 插件化
// packages/toast/index.js
import Vue from 'vue';
import ToastComponent from './index.vue';
const ToastConstructor = Vue.extend(ToastComponent);
let instance;
let instances = [];
let seed = 1;
const Toast = function(options = {}) {
if (typeof options === 'string') {
options = {
message: options
};
}
const id = 'toast_' + seed++;
instance = new ToastConstructor({
data: options
});
instance.id = id;
instance.$mount();
document.body.appendChild(instance.$el);
instance.show();
instance.$on('close', () => {
Toast.close(id);
});
instances.push(instance);
return instance;
};
// 快捷方法
['success', 'error', 'warning', 'loading'].forEach(type => {
Toast[type] = function(options = {}) {
if (typeof options === 'string') {
options = {
message: options
};
}
options.type = type;
return Toast(options);
};
});
// 关闭指定 Toast
Toast.close = function(id) {
const index = instances.findIndex(instance => instance.id === id);
if (index !== -1) {
const instance = instances[index];
instances.splice(index, 1);
if (instance.$el && instance.$el.parentNode) {
instance.$el.parentNode.removeChild(instance.$el);
}
instance.$destroy();
}
};
// 关闭所有 Toast
Toast.clear = function() {
instances.forEach(instance => {
if (instance.$el && instance.$el.parentNode) {
instance.$el.parentNode.removeChild(instance.$el);
}
instance.$destroy();
});
instances = [];
};
// Vue 插件安装
Toast.install = function(Vue) {
Vue.prototype.$toast = Toast;
};
export default Toast;
Popup 弹出层组件
<!-- packages/popup/index.vue -->
<template>
<view>
<!-- 遮罩层 -->
<view
v-if="visible"
class="my-popup__overlay"
:class="{ 'my-popup__overlay--transparent': !overlay }"
@click="handleOverlayClick"
></view>
<!-- 弹出内容 -->
<view
v-if="visible"
class="my-popup"
:class="popupClass"
:style="popupStyle"
>
<!-- 关闭按钮 -->
<view v-if="closeable" class="my-popup__close" @click="close">
<text class="iconfont icon-close"></text>
</view>
<!-- 内容区域 -->
<view class="my-popup__content">
<slot></slot>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'MyPopup',
props: {
// 是否显示
value: {
type: Boolean,
default: false
},
// 弹出位置
position: {
type: String,
default: 'center',
validator: (value) => {
return ['center', 'top', 'bottom', 'left', 'right'].includes(value);
}
},
// 是否显示遮罩
overlay: {
type: Boolean,
default: true
},
// 点击遮罩是否关闭
closeOnClickOverlay: {
type: Boolean,
default: true
},
// 是否显示关闭按钮
closeable: {
type: Boolean,
default: false
},
// 自定义样式
customStyle: {
type: Object,
default: () => ({})
},
// 圆角
round: {
type: Boolean,
default: false
},
// 安全区域适配
safeAreaInsetBottom: {
type: Boolean,
default: false
},
// 动画时长
duration: {
type: Number,
default: 300
}
},
data() {
return {
visible: this.value
};
},
computed: {
popupClass() {
return [
`my-popup--${this.position}`,
{
'my-popup--round': this.round,
'my-popup--safe-area': this.safeAreaInsetBottom
}
];
},
popupStyle() {
return {
...this.customStyle,
transitionDuration: `${this.duration}ms`
};
}
},
watch: {
value(newVal) {
this.visible = newVal;
},
visible(newVal) {
this.$emit('input', newVal);
if (newVal) {
this.$emit('open');
} else {
this.$emit('close');
}
}
},
methods: {
open() {
this.visible = true;
},
close() {
this.visible = false;
},
handleOverlayClick() {
if (this.closeOnClickOverlay) {
this.close();
}
}
}
};
</script>
<style scoped>
.my-popup__overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.my-popup__overlay--transparent {
background-color: transparent;
}
.my-popup {
position: fixed;
z-index: 1000;
background-color: #fff;
transition: all 0.3s;
}
.my-popup--center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90%;
max-height: 90%;
}
.my-popup--top {
top: 0;
left: 0;
width: 100%;
transform: translateY(-100%);
}
.my-popup--top.my-popup {
transform: translateY(0);
}
.my-popup--bottom {
bottom: 0;
left: 0;
width: 100%;
transform: translateY(100%);
}
.my-popup--bottom.my-popup {
transform: translateY(0);
}
.my-popup--left {
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
}
.my-popup--left.my-popup {
transform: translateX(0);
}
.my-popup--right {
top: 0;
right: 0;
height: 100%;
transform: translateX(100%);
}
.my-popup--right.my-popup {
transform: translateX(0);
}
.my-popup--round {
border-radius: 16px;
}
.my-popup--round.my-popup--top {
border-radius: 0 0 16px 16px;
}
.my-popup--round.my-popup--bottom {
border-radius: 16px 16px 0 0;
}
.my-popup--safe-area {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.my-popup__close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 50%;
z-index: 1;
}
.my-popup__content {
width: 100%;
height: 100%;
overflow: auto;
}
</style>
6.4.3 组件文档生成
组件文档模板
<!-- packages/button/README.md -->
# Button 按钮
按钮用于触发一个操作,如提交表单。
## 引入
```javascript
import { Button } from 'my-ui';
export default {
components: {
Button
}
};
代码演示
按钮类型
<template>
<view>
<my-button>默认按钮</my-button>
<my-button type="primary">主要按钮</my-button>
<my-button type="success">成功按钮</my-button>
<my-button type="warning">警告按钮</my-button>
<my-button type="danger">危险按钮</my-button>
</view>
</template>
朴素按钮
<template>
<view>
<my-button plain>朴素按钮</my-button>
<my-button type="primary" plain>主要按钮</my-button>
</view>
</template>
按钮尺寸
<template>
<view>
<my-button size="large">大号按钮</my-button>
<my-button size="normal">普通按钮</my-button>
<my-button size="small">小型按钮</my-button>
<my-button size="mini">迷你按钮</my-button>
</view>
</template>
禁用状态
<template>
<view>
<my-button disabled>禁用状态</my-button>
<my-button type="primary" disabled>禁用状态</my-button>
</view>
</template>
加载状态
<template>
<view>
<my-button loading>加载中</my-button>
<my-button type="primary" loading>加载中</my-button>
</view>
</template>
图标按钮
<template>
<view>
<my-button icon="icon-plus">添加</my-button>
<my-button type="primary" icon="icon-search">搜索</my-button>
</view>
</template>
API
Props
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
type | 类型,可选值为 primary success warning danger info |
string | default |
size | 尺寸,可选值为 large normal small mini |
string | normal |
disabled | 是否禁用按钮 | boolean | false |
loading | 是否显示为加载状态 | boolean | false |
round | 是否为圆角按钮 | boolean | false |
plain | 是否为朴素按钮 | boolean | false |
icon | 左侧图标名称 | string | - |
debounce | 防抖延迟,单位毫秒 | number | 300 |
Events
事件名 | 说明 | 回调参数 |
---|---|---|
click | 点击按钮时触发 | event: Event |
Slots
名称 | 说明 |
---|---|
default | 按钮内容 |
主题定制
样式变量
组件提供了下列 CSS 变量,可用于自定义样式。
名称 | 默认值 | 描述 |
---|---|---|
–button-height | 44px | 按钮高度 |
–button-font-size | 16px | 按钮字体大小 |
–button-border-radius | 4px | 按钮圆角 |
–button-primary-color | #1989fa | 主要按钮颜色 |
–button-success-color | #07c160 | 成功按钮颜色 |
–button-warning-color | #ff976a | 警告按钮颜色 |
–button-danger-color | #ee0a24 | 危险按钮颜色 |
### 6.4.4 组件库构建配置
#### Webpack 配置
```javascript
// build/webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
mode: 'production',
entry: {
index: './packages/index.js'
},
output: {
path: path.resolve(__dirname, '../lib'),
filename: '[name].js',
library: 'MyUI',
libraryTarget: 'umd',
umdNamedDefine: true
},
externals: {
vue: {
root: 'Vue',
commonjs: 'vue',
commonjs2: 'vue',
amd: 'vue'
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader',
'postcss-loader'
]
},
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
'postcss-loader',
'sass-loader'
]
}
]
},
plugins: [
new VueLoaderPlugin()
],
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'@': path.resolve(__dirname, '../packages')
}
}
};
构建脚本
{
"name": "my-ui",
"version": "1.0.0",
"description": "A Vue.js UI library for UniApp",
"main": "lib/index.js",
"files": [
"lib",
"packages"
],
"scripts": {
"build": "webpack --config build/webpack.config.js",
"build:lib": "npm run build",
"dev": "webpack-dev-server --config build/webpack.dev.js",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint packages/**/*.{js,vue}",
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs"
},
"keywords": [
"vue",
"uniapp",
"ui",
"components"
],
"author": "Your Name",
"license": "MIT",
"peerDependencies": {
"vue": "^2.6.0"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@vue/test-utils": "^1.0.0",
"babel-loader": "^8.0.0",
"css-loader": "^3.0.0",
"eslint": "^6.0.0",
"eslint-plugin-vue": "^6.0.0",
"jest": "^24.0.0",
"sass": "^1.0.0",
"sass-loader": "^8.0.0",
"vue-loader": "^15.0.0",
"vue-style-loader": "^4.0.0",
"vue-template-compiler": "^2.6.0",
"vuepress": "^1.0.0",
"webpack": "^4.0.0",
"webpack-cli": "^3.0.0",
"webpack-dev-server": "^3.0.0"
}
}
6.5 组件测试
6.5.1 单元测试
Jest 配置
// jest.config.js
module.exports = {
moduleFileExtensions: [
'js',
'json',
'vue'
],
transform: {
'^.+\.vue$': 'vue-jest',
'.+\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\.jsx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/'
],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/packages/$1'
},
snapshotSerializers: [
'jest-serializer-vue'
],
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
testURL: 'http://localhost/',
collectCoverage: true,
collectCoverageFrom: [
'packages/**/*.{js,vue}',
'!packages/index.js',
'!**/node_modules/**'
]
};
组件测试示例
// tests/unit/button.spec.js
import { mount } from '@vue/test-utils';
import Button from '@/button/index.vue';
describe('Button', () => {
// 基础渲染测试
it('renders correctly', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
});
expect(wrapper.text()).toBe('Click me');
expect(wrapper.classes()).toContain('my-button');
});
// Props 测试
it('renders different types', () => {
const wrapper = mount(Button, {
propsData: {
type: 'primary'
}
});
expect(wrapper.classes()).toContain('my-button--primary');
});
it('renders different sizes', () => {
const wrapper = mount(Button, {
propsData: {
size: 'large'
}
});
expect(wrapper.classes()).toContain('my-button--large');
});
it('renders disabled state', () => {
const wrapper = mount(Button, {
propsData: {
disabled: true
}
});
expect(wrapper.classes()).toContain('my-button--disabled');
expect(wrapper.attributes('disabled')).toBe('disabled');
});
it('renders loading state', () => {
const wrapper = mount(Button, {
propsData: {
loading: true
}
});
expect(wrapper.classes()).toContain('my-button--loading');
expect(wrapper.find('.loading-spinner').exists()).toBe(true);
});
// 事件测试
it('emits click event', () => {
const wrapper = mount(Button);
wrapper.trigger('click');
expect(wrapper.emitted().click).toBeTruthy();
});
it('does not emit click when disabled', () => {
const wrapper = mount(Button, {
propsData: {
disabled: true
}
});
wrapper.trigger('click');
expect(wrapper.emitted().click).toBeFalsy();
});
it('does not emit click when loading', () => {
const wrapper = mount(Button, {
propsData: {
loading: true
}
});
wrapper.trigger('click');
expect(wrapper.emitted().click).toBeFalsy();
});
// 防抖测试
it('debounces click events', (done) => {
const wrapper = mount(Button, {
propsData: {
debounce: 100
}
});
// 快速点击多次
wrapper.trigger('click');
wrapper.trigger('click');
wrapper.trigger('click');
// 应该没有立即触发事件
expect(wrapper.emitted().click).toBeFalsy();
// 等待防抖时间后检查
setTimeout(() => {
expect(wrapper.emitted().click).toBeTruthy();
expect(wrapper.emitted().click.length).toBe(1);
done();
}, 150);
});
// 快照测试
it('matches snapshot', () => {
const wrapper = mount(Button, {
propsData: {
type: 'primary',
size: 'large'
},
slots: {
default: 'Primary Button'
}
});
expect(wrapper.html()).toMatchSnapshot();
});
});
6.5.2 集成测试
// tests/integration/toast.spec.js
import { mount, createLocalVue } from '@vue/test-utils';
import Toast from '@/toast/index.js';
const localVue = createLocalVue();
localVue.use(Toast);
describe('Toast Integration', () => {
let wrapper;
beforeEach(() => {
// 清理之前的 Toast 实例
Toast.clear();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
Toast.clear();
});
it('shows toast through plugin', () => {
const TestComponent = {
template: '<div><button @click="showToast">Show Toast</button></div>',
methods: {
showToast() {
this.$toast('Hello World');
}
}
};
wrapper = mount(TestComponent, { localVue });
wrapper.find('button').trigger('click');
// 检查 Toast 是否被创建
const toastElement = document.querySelector('.my-toast');
expect(toastElement).toBeTruthy();
expect(toastElement.textContent).toContain('Hello World');
});
it('shows different types of toast', () => {
Toast.success('Success message');
Toast.error('Error message');
Toast.warning('Warning message');
const toastElements = document.querySelectorAll('.my-toast');
expect(toastElements.length).toBe(3);
});
it('auto closes toast after duration', (done) => {
Toast({
message: 'Auto close',
duration: 100
});
setTimeout(() => {
const toastElement = document.querySelector('.my-toast');
expect(toastElement).toBeFalsy();
done();
}, 150);
});
it('clears all toasts', () => {
Toast('Toast 1');
Toast('Toast 2');
Toast('Toast 3');
let toastElements = document.querySelectorAll('.my-toast');
expect(toastElements.length).toBe(3);
Toast.clear();
toastElements = document.querySelectorAll('.my-toast');
expect(toastElements.length).toBe(0);
});
});
6.5.3 E2E 测试
// tests/e2e/button.e2e.js
describe('Button E2E Tests', () => {
beforeEach(() => {
cy.visit('/examples/button');
});
it('should render different button types', () => {
cy.get('[data-test="default-button"]').should('be.visible');
cy.get('[data-test="primary-button"]').should('be.visible').and('have.class', 'my-button--primary');
cy.get('[data-test="success-button"]').should('be.visible').and('have.class', 'my-button--success');
});
it('should handle click events', () => {
cy.get('[data-test="click-button"]').click();
cy.get('[data-test="click-count"]').should('contain', '1');
cy.get('[data-test="click-button"]').click();
cy.get('[data-test="click-count"]').should('contain', '2');
});
it('should not click when disabled', () => {
cy.get('[data-test="disabled-button"]').should('be.disabled');
cy.get('[data-test="disabled-button"]').click({ force: true });
cy.get('[data-test="click-count"]').should('contain', '0');
});
it('should show loading state', () => {
cy.get('[data-test="loading-button"]').click();
cy.get('[data-test="loading-button"]').should('have.class', 'my-button--loading');
cy.get('.loading-spinner').should('be.visible');
});
});
6.6 本章总结
在本章中,我们深入学习了 UniApp 中的组件开发与复用,主要包括以下内容:
学习要点回顾
自定义组件基础
- 组件创建与注册方法
- 全局注册和局部注册
- easycom 组件规范的使用
- 组件的生命周期和最佳实践
组件通信
- Props 和 Events 的使用
- Provide/Inject 依赖注入
- 事件总线(Event Bus)
- 不同场景下的通信选择
高级组件模式
- 高阶组件(HOC)的实现
- 渲染函数和 JSX 的使用
- 插槽(Slots)的进阶用法
- 作用域插槽和具名插槽
组件库开发
- 组件库架构设计
- 通用组件开发(Toast、Popup 等)
- 组件文档生成
- 构建配置和发布流程
组件测试
- 单元测试的编写
- 集成测试和 E2E 测试
- 测试覆盖率和质量保证
- 自动化测试流程
实践练习
创建自定义组件
- 开发一个多功能的表单组件
- 实现数据验证和错误提示
- 支持多种输入类型和自定义样式
- 添加防抖和节流功能
实现组件通信
- 创建一个购物车系统
- 使用不同的通信方式连接组件
- 实现数据的双向绑定和状态同步
- 添加事件监听和清理机制
开发组件库
- 设计并实现一套完整的 UI 组件库
- 包含常用的基础组件和业务组件
- 编写详细的文档和使用示例
- 配置构建和发布流程
常见问题解答
Q: 如何选择合适的组件通信方式? A: 父子组件使用 Props/Events,跨级组件使用 Provide/Inject,兄弟组件使用事件总线或状态管理,复杂应用推荐使用 Vuex/Pinia。
Q: 如何优化组件的性能? A: 使用 v-memo、computed、watch 优化响应式数据,合理使用 v-if/v-show,避免不必要的重新渲染,使用虚拟滚动处理大量数据。
Q: 如何设计可复用的组件? A: 遵循单一职责原则,提供灵活的 Props 配置,使用插槽支持内容定制,保持 API 的一致性和向后兼容性。
Q: 如何处理组件的样式隔离? A: 使用 scoped CSS、CSS Modules 或 CSS-in-JS,避免全局样式污染,提供主题定制能力。
Q: 如何测试组件的交互行为? A: 使用 @vue/test-utils 进行单元测试,模拟用户交互,测试事件触发和数据变化,使用快照测试验证渲染结果。
最佳实践建议
组件设计
- 保持组件的单一职责和高内聚
- 提供清晰的 API 和文档
- 支持主题定制和国际化
- 考虑无障碍访问和用户体验
代码质量
- 使用 TypeScript 提供类型安全
- 编写完整的测试用例
- 遵循代码规范和最佳实践
- 定期重构和优化代码
性能优化
- 合理使用组件缓存和懒加载
- 优化组件的渲染性能
- 减少不必要的依赖和包体积
- 监控和分析性能指标
维护管理
- 建立版本管理和发布流程
- 提供迁移指南和变更日志
- 收集用户反馈和需求
- 持续改进和功能迭代
团队协作
- 制定组件开发规范
- 建立代码审查机制
- 分享最佳实践和经验
- 培训和知识传递
通过本章的学习,你应该能够熟练地开发自定义组件、实现组件通信、构建组件库并进行有效的测试。下一章我们将学习《性能优化与调试》,包括性能分析、优化策略、调试技巧等内容。
6.1.2 组件注册方式
全局注册
// main.js
import Vue from 'vue';
import MyButton from '@/components/MyButton/MyButton.vue';
// 全局注册组件
Vue.component('MyButton', MyButton);
// 或者批量注册
const components = {
MyButton: () => import('@/components/MyButton/MyButton.vue'),
MyInput: () => import('@/components/MyInput/MyInput.vue'),
MyModal: () => import('@/components/MyModal/MyModal.vue')
};
Object.keys(components).forEach(key => {
Vue.component(key, components[key]);
});
局部注册
<!-- pages/index/index.vue -->
<template>
<view class="container">
<MyButton type="primary" @click="handleClick">点击我</MyButton>
<MyButton type="success" size="small" icon="icon-check">成功</MyButton>
<MyButton type="danger" :loading="loading" @click="handleAsyncAction">异步操作</MyButton>
</view>
</template>
<script>
import MyButton from '@/components/MyButton/MyButton.vue';
export default {
components: {
MyButton
},
data() {
return {
loading: false
};
},
methods: {
handleClick() {
uni.showToast({
title: '按钮被点击了',
icon: 'none'
});
},
async handleAsyncAction() {
this.loading = true;
try {
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 2000));
uni.showToast({
title: '操作成功',
icon: 'success'
});
} catch (error) {
uni.showToast({
title: '操作失败',
icon: 'error'
});
} finally {
this.loading = false;
}
}
}
};
</script>
6.1.3 easycom 组件规范
UniApp 支持 easycom 组件规范,可以自动引入组件,无需手动注册。
// pages.json
{
"easycom": {
"autoscan": true,
"custom": {
"^my-(.*)$": "@/components/my-$1/my-$1.vue",
"^uni-(.*)$": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"pages": [
// 页面配置
]
}
使用 easycom 规范后,可以直接在模板中使用组件:
<template>
<view>
<!-- 自动引入 @/components/my-button/my-button.vue -->
<my-button type="primary">按钮</my-button>
<!-- 自动引入 @/components/my-input/my-input.vue -->
<my-input v-model="value" placeholder="请输入内容"></my-input>
</view>
</template>
6.2 组件通信
6.2.1 Props 和 Events
父组件向子组件传递数据(Props)
<!-- components/UserCard/UserCard.vue -->
<template>
<view class="user-card" :class="{ 'user-card--active': active }">
<image class="user-avatar" :src="user.avatar" @error="handleAvatarError"></image>
<view class="user-info">
<text class="user-name">{{ user.name }}</text>
<text class="user-desc">{{ user.description }}</text>
<view class="user-tags">
<text
v-for="tag in user.tags"
:key="tag"
class="user-tag"
>
{{ tag }}
</text>
</view>
</view>
<view class="user-actions">
<button @click="handleFollow" class="follow-btn" :class="{ followed: isFollowed }">
{{ isFollowed ? '已关注' : '关注' }}
</button>
<button @click="handleMessage" class="message-btn">私信</button>
</view>
</view>
</template>
<script>
export default {
name: 'UserCard',
props: {
// 用户信息对象
user: {
type: Object,
required: true,
default: () => ({}),
validator: (value) => {
return value && typeof value.name === 'string';
}
},
// 是否激活状态
active: {
type: Boolean,
default: false
},
// 是否已关注
followed: {
type: Boolean,
default: false
},
// 是否显示操作按钮
showActions: {
type: Boolean,
default: true
}
},
data() {
return {
isFollowed: this.followed,
defaultAvatar: '/static/images/default-avatar.png'
};
},
watch: {
followed(newVal) {
this.isFollowed = newVal;
}
},
methods: {
handleFollow() {
this.isFollowed = !this.isFollowed;
this.$emit('follow', {
user: this.user,
followed: this.isFollowed
});
},
handleMessage() {
this.$emit('message', this.user);
},
handleAvatarError() {
// 头像加载失败时使用默认头像
this.$emit('avatar-error', this.user);
}
}
};
</script>
<style scoped>
.user-card {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-radius: 8px;
margin-bottom: 12px;
transition: all 0.3s;
}
.user-card--active {
border: 2px solid #1989fa;
box-shadow: 0 2px 12px rgba(25, 137, 250, 0.2);
}
.user-avatar {
width: 60px;
height: 60px;
border-radius: 30px;
margin-right: 12px;
}
.user-info {
flex: 1;
}
.user-name {
display: block;
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.user-desc {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.user-tags {
display: flex;
flex-wrap: wrap;
}
.user-tag {
padding: 2px 8px;
margin-right: 8px;
margin-bottom: 4px;
font-size: 12px;
background-color: #f0f0f0;
color: #666;
border-radius: 12px;
}
.user-actions {
display: flex;
flex-direction: column;
}
.follow-btn,
.message-btn {
padding: 8px 16px;
margin-bottom: 8px;
font-size: 14px;
border-radius: 4px;
border: 1px solid #ddd;
background-color: #fff;
color: #333;
}
.follow-btn.followed {
background-color: #1989fa;
border-color: #1989fa;
color: #fff;
}
.message-btn {
background-color: #f8f8f8;
}
</style>
子组件向父组件传递数据(Events)
<!-- pages/users/users.vue -->
<template>
<view class="users-page">
<view class="search-bar">
<input
v-model="searchKeyword"
placeholder="搜索用户"
@input="handleSearch"
class="search-input"
/>
</view>
<scroll-view class="users-list" scroll-y>
<UserCard
v-for="user in filteredUsers"
:key="user.id"
:user="user"
:active="selectedUserId === user.id"
:followed="user.followed"
@follow="handleUserFollow"
@message="handleUserMessage"
@avatar-error="handleAvatarError"
@click="handleUserClick"
/>
</scroll-view>
</view>
</template>
<script>
import UserCard from '@/components/UserCard/UserCard.vue';
export default {
components: {
UserCard
},
data() {
return {
searchKeyword: '',
selectedUserId: null,
users: [
{
id: 1,
name: '张三',
description: '前端开发工程师',
avatar: '/static/images/avatar1.jpg',
tags: ['Vue', 'JavaScript', 'CSS'],
followed: false
},
{
id: 2,
name: '李四',
description: '后端开发工程师',
avatar: '/static/images/avatar2.jpg',
tags: ['Node.js', 'Python', 'MySQL'],
followed: true
}
]
};
},
computed: {
filteredUsers() {
if (!this.searchKeyword) {
return this.users;
}
return this.users.filter(user =>
user.name.includes(this.searchKeyword) ||
user.description.includes(this.searchKeyword) ||
user.tags.some(tag => tag.includes(this.searchKeyword))
);
}
},
methods: {
handleSearch() {
// 搜索防抖处理
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
console.log('搜索关键词:', this.searchKeyword);
}, 300);
},
handleUserFollow(data) {
const { user, followed } = data;
console.log(`${followed ? '关注' : '取消关注'}用户:`, user.name);
// 更新用户关注状态
const targetUser = this.users.find(u => u.id === user.id);
if (targetUser) {
targetUser.followed = followed;
}
// 显示提示
uni.showToast({
title: followed ? '关注成功' : '取消关注',
icon: 'success'
});
},
handleUserMessage(user) {
console.log('发送私信给用户:', user.name);
uni.navigateTo({
url: `/pages/chat/chat?userId=${user.id}&userName=${user.name}`
});
},
handleAvatarError(user) {
console.log('用户头像加载失败:', user.name);
// 可以在这里处理头像加载失败的逻辑
},
handleUserClick(user) {
this.selectedUserId = user.id;
console.log('选中用户:', user.name);
}
},
beforeDestroy() {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
}
}
};
</script>
<style>
.users-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
}
.search-bar {
padding: 16px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
font-size: 16px;
background-color: #f8f8f8;
}
.users-list {
flex: 1;
padding: 16px;
}
</style>
6.2.2 Provide/Inject 依赖注入
对于深层嵌套的组件通信,可以使用 provide/inject 模式:
<!-- components/ThemeProvider/ThemeProvider.vue -->
<template>
<view class="theme-provider" :class="`theme-${currentTheme}`">
<slot></slot>
</view>
</template>
<script>
export default {
name: 'ThemeProvider',
props: {
theme: {
type: String,
default: 'light'
}
},
data() {
return {
currentTheme: this.theme
};
},
provide() {
return {
theme: this.currentTheme,
changeTheme: this.changeTheme,
getThemeColor: this.getThemeColor
};
},
methods: {
changeTheme(theme) {
this.currentTheme = theme;
this.$emit('theme-change', theme);
},
getThemeColor(type) {
const themes = {
light: {
primary: '#1989fa',
background: '#fff',
text: '#333'
},
dark: {
primary: '#409eff',
background: '#1a1a1a',
text: '#fff'
}
};
return themes[this.currentTheme][type];
}
}
};
</script>
<style>
.theme-light {
background-color: #fff;
color: #333;
}
.theme-dark {
background-color: #1a1a1a;
color: #fff;
}
</style>
<!-- components/ThemedButton/ThemedButton.vue -->
<template>
<button
class="themed-button"
:style="buttonStyle"
@click="$emit('click', $event)"
>
<slot></slot>
</button>
</template>
<script>
export default {
name: 'ThemedButton',
inject: ['theme', 'getThemeColor'],
computed: {
buttonStyle() {
return {
backgroundColor: this.getThemeColor('primary'),
color: this.getThemeColor('background'),
border: `1px solid ${this.getThemeColor('primary')}`
};
}
}
};
</script>
<style scoped>
.themed-button {
padding: 12px 24px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
.themed-button:hover {
opacity: 0.8;
}
</style>
6.2.3 事件总线(Event Bus)
对于非父子组件间的通信,可以使用事件总线:
// utils/eventBus.js
import Vue from 'vue';
class EventBus {
constructor() {
this.vue = new Vue();
}
// 监听事件
on(event, callback) {
this.vue.$on(event, callback);
}
// 触发事件
emit(event, data) {
this.vue.$emit(event, data);
}
// 移除事件监听
off(event, callback) {
this.vue.$off(event, callback);
}
// 只监听一次
once(event, callback) {
this.vue.$once(event, callback);
}
// 移除所有监听器
clear() {
this.vue.$off();
}
}
const eventBus = new EventBus();
export default eventBus;
使用事件总线进行组件通信:
<!-- components/CartButton/CartButton.vue -->
<template>
<button class="cart-button" @click="addToCart">
<text class="cart-icon">🛒</text>
<text class="cart-count" v-if="cartCount > 0">{{ cartCount }}</text>
</button>
</template>
<script>
import eventBus from '@/utils/eventBus';
export default {
name: 'CartButton',
props: {
product: {
type: Object,
required: true
}
},
data() {
return {
cartCount: 0
};
},
mounted() {
// 监听购物车更新事件
eventBus.on('cart-updated', this.handleCartUpdate);
},
beforeDestroy() {
// 移除事件监听
eventBus.off('cart-updated', this.handleCartUpdate);
},
methods: {
addToCart() {
// 触发添加到购物车事件
eventBus.emit('add-to-cart', {
product: this.product,
quantity: 1
});
},
handleCartUpdate(data) {
this.cartCount = data.totalCount;
}
}
};
</script>
<!-- components/CartManager/CartManager.vue -->
<template>
<view class="cart-manager">
<!-- 购物车内容 -->
</view>
</template>
<script>
import eventBus from '@/utils/eventBus';
export default {
name: 'CartManager',
data() {
return {
cartItems: [],
totalCount: 0
};
},
mounted() {
// 监听添加到购物车事件
eventBus.on('add-to-cart', this.handleAddToCart);
},
beforeDestroy() {
eventBus.off('add-to-cart', this.handleAddToCart);
},
methods: {
handleAddToCart(data) {
const { product, quantity } = data;
// 添加商品到购物车
const existingItem = this.cartItems.find(item => item.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.cartItems.push({
...product,
quantity
});
}
// 更新总数量
this.updateTotalCount();
// 通知其他组件购物车已更新
eventBus.emit('cart-updated', {
items: this.cartItems,
totalCount: this.totalCount
});
// 显示提示
uni.showToast({
title: '已添加到购物车',
icon: 'success'
});
},
updateTotalCount() {
this.totalCount = this.cartItems.reduce((total, item) => total + item.quantity, 0);
}
}
};
</script>
6.3 高级组件模式
6.3.1 高阶组件(HOC)
高阶组件是一个函数,接收一个组件并返回一个新的组件:
// mixins/withLoading.js
export function withLoading(WrappedComponent) {
return {
name: `WithLoading${WrappedComponent.name}`,
props: {
loading: {
type: Boolean,
default: false
},
loadingText: {
type: String,
default: '加载中...'
}
},
render(h) {
if (this.loading) {
return h('view', {
class: 'loading-wrapper'
}, [
h('view', {
class: 'loading-spinner'
}),
h('text', {
class: 'loading-text'
}, this.loadingText)
]);
}
return h(WrappedComponent, {
props: this.$props,
attrs: this.$attrs,
on: this.$listeners
}, this.$slots.default);
}
};
}
6.3.2 渲染函数和 JSX
// components/DynamicList/DynamicList.js
export default {
name: 'DynamicList',
props: {
items: {
type: Array,
default: () => []
},
renderItem: {
type: Function,
required: true
},
keyField: {
type: String,
default: 'id'
}
},
render(h) {
const listItems = this.items.map((item, index) => {
return h('view', {
key: item[this.keyField] || index,
class: 'list-item'
}, [
this.renderItem(h, item, index)
]);
});
return h('view', {
class: 'dynamic-list'
}, listItems);
}
};
使用动态列表组件:
<template>
<view>
<DynamicList
:items="products"
:render-item="renderProduct"
key-field="id"
/>
</view>
</template>
<script>
import DynamicList from '@/components/DynamicList/DynamicList.js';
export default {
components: {
DynamicList
},
data() {
return {
products: [
{ id: 1, name: '商品1', price: 100 },
{ id: 2, name: '商品2', price: 200 }
]
};
},
methods: {
renderProduct(h, product, index) {
return h('view', {
class: 'product-item',
on: {
click: () => this.handleProductClick(product)
}
}, [
h('text', { class: 'product-name' }, product.name),
h('text', { class: 'product-price' }, `¥${product.price}`)
]);
},
handleProductClick(product) {
console.log('点击商品:', product);
}
}
};
</script>
6.3.3 插槽(Slots)进阶用法
具名插槽和作用域插槽
<!-- components/DataTable/DataTable.vue -->
<template>
<view class="data-table">
<!-- 表头 -->
<view class="table-header">
<slot name="header" :columns="columns">
<view class="default-header">
<text v-for="column in columns" :key="column.key" class="header-cell">
{{ column.title }}
</text>
</view>
</slot>
</view>
<!-- 表格内容 -->
<scroll-view class="table-body" scroll-y>
<view v-for="(row, rowIndex) in data" :key="rowIndex" class="table-row">
<slot name="row" :row="row" :index="rowIndex" :columns="columns">
<!-- 默认行渲染 -->
<view v-for="column in columns" :key="column.key" class="table-cell">
<slot
:name="`cell-${column.key}`"
:value="row[column.key]"
:row="row"
:column="column"
:index="rowIndex"
>
{{ row[column.key] }}
</slot>
</view>
</slot>
</view>
</scroll-view>
<!-- 表尾 -->
<view class="table-footer" v-if="$slots.footer">
<slot name="footer" :data="data" :total="total"></slot>
</view>
<!-- 空状态 -->
<view v-if="data.length === 0" class="empty-state">
<slot name="empty">
<text class="empty-text">暂无数据</text>
</slot>
</view>
</view>
</template>
<script>
export default {
name: 'DataTable',
props: {
columns: {
type: Array,
required: true
},
data: {
type: Array,
default: () => []
}
},
computed: {
total() {
return this.data.length;
}
}
};
</script>
<style scoped>
.data-table {
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.table-header {
background-color: #f5f5f5;
border-bottom: 1px solid #eee;
}
.default-header {
display: flex;
}
.header-cell {
flex: 1;
padding: 12px;
font-weight: bold;
text-align: center;
border-right: 1px solid #eee;
}
.table-body {
max-height: 400px;
}
.table-row {
display: flex;
border-bottom: 1px solid #eee;
}
.table-row:hover {
background-color: #f9f9f9;
}
.table-cell {
flex: 1;
padding: 12px;
text-align: center;
border-right: 1px solid #eee;
}
.table-footer {
padding: 12px;
background-color: #f5f5f5;
border-top: 1px solid #eee;
}
.empty-state {
padding: 40px;
text-align: center;
}
.empty-text {
color: #999;
font-size: 14px;
}
</style>
使用数据表格组件:
<template>
<view class="page">
<DataTable :columns="columns" :data="users">
<!-- 自定义表头 -->
<template #header="{ columns }">
<view class="custom-header">
<text v-for="column in columns" :key="column.key" class="header-title">
{{ column.title }}
<text v-if="column.sortable" class="sort-icon">↕</text>
</text>
</view>
</template>
<!-- 自定义头像列 -->
<template #cell-avatar="{ value, row }">
<image class="user-avatar" :src="value" @error="handleAvatarError"></image>
</template>
<!-- 自定义状态列 -->
<template #cell-status="{ value }">
<text class="status-badge" :class="`status-${value}`">
{{ getStatusText(value) }}
</text>
</template>
<!-- 自定义操作列 -->
<template #cell-actions="{ row }">
<view class="action-buttons">
<button @click="editUser(row)" class="edit-btn">编辑</button>
<button @click="deleteUser(row)" class="delete-btn">删除</button>
</view>
</template>
<!-- 自定义表尾 -->
<template #footer="{ total }">
<text class="footer-text">共 {{ total }} 条记录</text>
</template>
<!-- 自定义空状态 -->
<template #empty>
<view class="custom-empty">
<text class="empty-icon">📭</text>
<text class="empty-message">还没有用户数据</text>
<button @click="addUser" class="add-btn">添加用户</button>
</view>
</template>
</DataTable>
</view>
</template>
<script>
import DataTable from '@/components/DataTable/DataTable.vue';
export default {
components: {
DataTable
},
data() {
return {
columns: [
{ key: 'avatar', title: '头像' },
{ key: 'name', title: '姓名', sortable: true },
{ key: 'email', title: '邮箱' },
{ key: 'status', title: '状态' },
{ key: 'actions', title: '操作' }
],
users: [
{
id: 1,
name: '张三',
email: 'zhangsan@example.com',
avatar: '/static/images/avatar1.jpg',
status: 'active'
},
{
id: 2,
name: '李四',
email: 'lisi@example.com',
avatar: '/static/images/avatar2.jpg',
status: 'inactive'
}
]
};
},
methods: {
getStatusText(status) {
const statusMap = {
active: '活跃',
inactive: '非活跃',
banned: '已禁用'
};
return statusMap[status] || '未知';
},
editUser(user) {
console.log('编辑用户:', user);
},
deleteUser(user) {
console.log('删除用户:', user);
},
addUser() {
console.log('添加用户');
},
handleAvatarError() {
console.log('头像加载失败');
}
}
};
</script>
<style>
.custom-header {
display: flex;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.header-title {
flex: 1;
padding: 12px;
text-align: center;
font-weight: bold;
}
.sort-icon {
margin-left: 4px;
opacity: 0.7;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 20px;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
}
.status-active {
background-color: #52c41a;
}
.status-inactive {
background-color: #faad14;
}
.status-banned {
background-color: #f5222d;
}
.action-buttons {
display: flex;
gap: 8px;
}
.edit-btn,
.delete-btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
border: none;
}
.edit-btn {
background-color: #1890ff;
color: white;
}
.delete-btn {
background-color: #ff4d4f;
color: white;
}
.footer-text {
color: #666;
font-size: 14px;
}
.custom-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-message {
color: #999;
margin-bottom: 16px;
}
.add-btn {
padding: 8px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
}
</style>