23 KiB
23 KiB
Uni-App-X 组件库规范 - CRMEB 风格
📚 概述
本文档定义了 mall 项目中可复用组件的开发规范,参考 CRMEB 的组件库设计。所有组件必须遵循此规范。
1. 组件分类体系
components/
├── basic/ # 基础组件
│ ├── Button.uvue
│ ├── Input.uvue
│ ├── Select.uvue
│ ├── Checkbox.uvue
│ ├── Radio.uvue
│ └── ...
├── container/ # 容器组件
│ ├── Card.uvue
│ ├── Modal.uvue
│ ├── Drawer.uvue
│ └── ...
├── data/ # 数据展示组件
│ ├── Table.uvue
│ ├── List.uvue
│ ├── Tree.uvue
│ └── ...
├── form/ # 表单组件
│ ├── Form.uvue
│ ├── FormItem.uvue
│ ├── DatePicker.uvue
│ └── ...
├── feedback/ # 反馈组件
│ ├── Message.uvue
│ ├── Toast.uvue
│ ├── Modal.uvue
│ └── ...
└── navigation/ # 导航组件
├── Tabs.uvue
├── Breadcrumb.uvue
└── ...
2. 基础组件规范
2.1 Button(按钮)
特性
- 4 种类型: primary / default / danger / success
- 3 种尺寸: sm / md / lg
- 支持禁用和加载状态
- 支持前置和后置图标
代码示例
<template>
<button
:class="[
'btn',
`btn-${type}`,
`btn-${size}`,
{
'is-loading': loading,
'is-disabled': disabled,
}
]"
:disabled="disabled || loading"
@click="handleClick"
>
<text v-if="icon && !loading" class="btn-icon-left">{{ icon }}</text>
<text v-if="loading" class="btn-spinner">⏳</text>
<text class="btn-text">{{ loading ? '加载中...' : label }}</text>
</button>
</template>
<script setup lang="uts">
import { defineProps, defineEmits } from 'vue'
const props = withDefaults(defineProps<{
type?: 'primary' | 'default' | 'danger' | 'success'
size?: 'sm' | 'md' | 'lg'
label: string
icon?: string
disabled?: boolean
loading?: boolean
}>(), {
type: 'primary',
size: 'md',
disabled: false,
loading: false,
})
const emit = defineEmits<{
click: []
}>()
const handleClick = () => {
if (!props.disabled && !props.loading) {
emit('click')
}
}
</script>
<style scoped lang="scss">
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-xs;
border: none;
border-radius: $radius-sm;
cursor: pointer;
transition: all $transition-duration $transition-timing;
font-weight: $font-weight-medium;
white-space: nowrap;
// 尺寸变体
&.btn-sm {
height: $btn-height-sm;
font-size: $font-size-sm;
padding: 0 $space-sm;
}
&.btn-md {
height: $btn-height;
font-size: $font-size;
padding: 0 $space-md;
}
&.btn-lg {
height: $btn-height-lg;
font-size: $font-size-md;
padding: 0 $space-lg;
}
// 类型变体
&.btn-primary {
background: $primary-color;
color: #fff;
&:not(.is-disabled):active {
opacity: 0.8;
}
}
&.btn-default {
background: $background-secondary;
color: $text-primary;
border: 1px solid $border-color;
&:not(.is-disabled):active {
background: $border-light;
}
}
&.btn-danger {
background: $error-color;
color: #fff;
&:not(.is-disabled):active {
opacity: 0.8;
}
}
&.btn-success {
background: $success-color;
color: #fff;
&:not(.is-disabled):active {
opacity: 0.8;
}
}
// 状态
&.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.is-loading {
pointer-events: none;
}
.btn-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-text {
font-size: inherit;
}
.btn-icon-left {
margin-right: -$space-xs;
}
</style>
使用示例
<script setup lang="uts">
const handleCreate = () => {
console.log('创建')
}
</script>
<template>
<!-- 主按钮 -->
<Button type="primary" label="创建" @click="handleCreate" />
<!-- 小按钮 -->
<Button type="default" size="sm" label="编辑" />
<!-- 加载状态 -->
<Button type="primary" :loading="loading" label="保存" @click="handleSave" />
<!-- 禁用状态 -->
<Button type="danger" disabled label="删除" />
</template>
2.2 Input(输入框)
特性
- 支持不同类型: text / password / number / email
- 支持前缀和后缀插槽
- 支持验证状态反馈
- 支持 clearable 清空功能
代码示例
<template>
<view :class="['input-wrapper', { 'is-error': error }]">
<view class="input-inner">
<text v-if="prefix" class="input-prefix">{{ prefix }}</text>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
class="input-field"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
/>
<button
v-if="clearable && modelValue && focused"
class="input-clear"
@click="handleClear"
>
✕
</button>
<text v-if="suffix" class="input-suffix">{{ suffix }}</text>
</view>
<text v-if="error" class="input-error">{{ error }}</text>
</view>
</template>
<script setup lang="uts">
import { ref, defineProps, defineEmits } from 'vue'
const props = withDefaults(defineProps<{
type?: 'text' | 'password' | 'number' | 'email'
modelValue?: string
placeholder?: string
disabled?: boolean
error?: string
prefix?: string
suffix?: string
clearable?: boolean
}>(), {
type: 'text',
modelValue: '',
clearable: true,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
focus: []
blur: []
}>()
const focused = ref(false)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const handleFocus = () => {
focused.value = true
emit('focus')
}
const handleBlur = () => {
focused.value = false
emit('blur')
}
const handleClear = () => {
emit('update:modelValue', '')
}
</script>
<style scoped lang="scss">
.input-wrapper {
display: flex;
flex-direction: column;
gap: $space-xs;
}
.input-inner {
display: flex;
align-items: center;
height: $input-height;
padding: 0 $space-sm;
border: 1px solid $border-color;
border-radius: $radius-xs;
background: $background-primary;
transition: all $transition-duration $transition-timing;
&:focus-within {
border-color: $primary-color;
box-shadow: 0 0 0 3px rgba($primary-color, 0.1);
}
}
.input-wrapper.is-error .input-inner {
border-color: $error-color;
box-shadow: 0 0 0 3px rgba($error-color, 0.1);
}
.input-prefix {
color: $text-tertiary;
margin-right: $space-sm;
flex: none;
}
.input-field {
flex: 1;
height: 100%;
border: none;
outline: none;
font-size: $font-size;
color: $text-primary;
background: transparent;
&::placeholder {
color: $text-tertiary;
}
&:disabled {
color: $text-disabled;
cursor: not-allowed;
}
}
.input-clear {
background: none;
border: none;
color: $text-tertiary;
padding: $space-xs;
cursor: pointer;
margin: 0 $space-xs;
&:hover {
color: $text-primary;
}
}
.input-suffix {
color: $text-tertiary;
margin-left: $space-sm;
flex: none;
}
.input-error {
font-size: $font-size-xs;
color: $error-color;
}
</style>
2.3 Select(下拉选择)
特性
- 支持单选和多选
- 支持搜索过滤
- 支持虚拟滚动(大数据)
- 支持自定义选项模板
示例
<template>
<view class="select-wrapper">
<view
class="select-trigger"
@click="toggleDropdown"
>
<text class="select-value">
{{ selectedLabel || placeholder }}
</text>
<text class="select-arrow">▼</text>
</view>
<view v-if="visible" class="select-dropdown">
<input
v-if="searchable"
class="select-search"
placeholder="搜索..."
@input="handleSearch"
/>
<view class="select-options">
<view
v-for="option in filteredOptions"
:key="option.value"
class="select-option"
:class="{ active: modelValue === option.value }"
@click="handleSelect(option)"
>
{{ option.label }}
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, defineProps, defineEmits } from 'vue'
const props = withDefaults(defineProps<{
options: Array<{ label: string; value: string }>
modelValue?: string
placeholder?: string
searchable?: boolean
}>(), {
searchable: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const visible = ref(false)
const searchQuery = ref('')
const selectedLabel = computed(() => {
return props.options.find(opt => opt.value === props.modelValue)?.label
})
const filteredOptions = computed(() => {
if (!searchQuery.value) {
return props.options
}
return props.options.filter(opt =>
opt.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const toggleDropdown = () => {
visible.value = !visible.value
}
const handleSelect = (option: any) => {
emit('update:modelValue', option.value)
visible.value = false
}
const handleSearch = (event: Event) => {
const target = event.target as HTMLInputElement
searchQuery.value = target.value
}
</script>
<style scoped lang="scss">
.select-wrapper {
position: relative;
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
height: $input-height;
padding: 0 $space-sm;
border: 1px solid $border-color;
border-radius: $radius-xs;
background: $background-primary;
cursor: pointer;
transition: all $transition-duration $transition-timing;
&:hover {
border-color: $primary-color;
}
}
.select-value {
color: $text-primary;
font-size: $font-size;
}
.select-arrow {
color: $text-tertiary;
margin-left: $space-sm;
transition: transform $transition-duration $transition-timing;
}
.select-wrapper.active .select-arrow {
transform: rotate(180deg);
}
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: $space-xs;
background: $background-primary;
border: 1px solid $border-color;
border-radius: $radius-xs;
box-shadow: $shadow-lg;
z-index: $z-index-dropdown;
}
.select-search {
width: 100%;
height: $input-height-sm;
padding: 0 $space-sm;
border: none;
border-bottom: 1px solid $border-light;
font-size: $font-size;
}
.select-options {
max-height: 250px;
overflow-y: auto;
}
.select-option {
padding: $space-sm $space-md;
cursor: pointer;
transition: background-color $transition-duration $transition-timing;
color: $text-primary;
font-size: $font-size;
&:hover {
background-color: $background-secondary;
}
&.active {
background-color: rgba($primary-color, 0.1);
color: $primary-color;
font-weight: $font-weight-medium;
}
}
</style>
3. 容器组件规范
3.1 Card(卡片)
<template>
<view :class="['card', shadowLevel]">
<view v-if="$slots.header" class="card-header">
<slot name="header" />
</view>
<view class="card-body">
<slot />
</view>
<view v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</view>
</view>
</template>
<script setup lang="uts">
import { defineProps } from 'vue'
const props = withDefaults(defineProps<{
shadowLevel?: 'sm' | 'md' | 'lg'
}>(), {
shadowLevel: 'sm',
})
</script>
<style scoped lang="scss">
.card {
background: $background-primary;
border-radius: $radius;
overflow: hidden;
&.sm {
box-shadow: $shadow-sm;
}
&.md {
box-shadow: $shadow;
}
&.lg {
box-shadow: $shadow-lg;
}
}
.card-header {
padding: $space-md;
border-bottom: 1px solid $border-light;
background: $background-secondary;
}
.card-body {
padding: $space-md;
}
.card-footer {
padding: $space-md;
border-top: 1px solid $border-light;
}
</style>
3.2 Modal(模态框)
<template>
<view v-if="visible" class="modal-overlay" @click="handleBackdropClick">
<view class="modal-dialog" @click="stopPropagation">
<view class="modal-header">
<text class="modal-title">{{ title }}</text>
<button class="modal-close" @click="handleClose">✕</button>
</view>
<view class="modal-body">
<slot />
</view>
<view class="modal-footer">
<button class="btn btn-default" @click="handleCancel">取消</button>
<button class="btn btn-primary" @click="handleConfirm">确认</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { defineProps, defineEmits } from 'vue'
const props = defineProps<{
visible: boolean
title: string
}>()
const emit = defineEmits<{
close: []
confirm: []
cancel: []
}>()
const handleBackdropClick = () => {
emit('close')
}
const handleClose = () => {
emit('close')
}
const handleCancel = () => {
emit('cancel')
}
const handleConfirm = () => {
emit('confirm')
}
const stopPropagation = (event: Event) => {
event.stopPropagation()
}
</script>
<style scoped lang="scss">
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-index-modal-backdrop;
}
.modal-dialog {
background: $background-primary;
border-radius: $radius;
width: 90%;
max-width: 500px;
box-shadow: $shadow-xl;
z-index: $z-index-modal;
}
.modal-header {
padding: $space-md;
border-bottom: 1px solid $border-light;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.modal-close {
background: none;
border: none;
color: $text-tertiary;
padding: 0;
cursor: pointer;
font-size: $font-size-lg;
&:hover {
color: $text-primary;
}
}
.modal-body {
padding: $space-md;
max-height: 60vh;
overflow-y: auto;
}
.modal-footer {
padding: $space-md;
border-top: 1px solid $border-light;
display: flex;
justify-content: flex-end;
gap: $space-sm;
}
</style>
4. 表单组件规范
4.1 Form(表单容器)
<template>
<form class="form" @submit.prevent="handleSubmit">
<slot />
</form>
</template>
<script setup lang="uts">
import { defineEmits } from 'vue'
const emit = defineEmits<{
submit: [formData: Record<string, any>]
}>()
const handleSubmit = () => {
// 实现表单验证逻辑
emit('submit', {})
}
</script>
<style scoped lang="scss">
.form {
display: flex;
flex-direction: column;
gap: $space-lg;
}
</style>
4.2 FormItem(表单项)
<template>
<view class="form-item">
<label class="form-label">
<text v-if="required" class="required-mark">*</text>
{{ label }}
</label>
<slot />
<text v-if="error" class="form-error">{{ error }}</text>
</view>
</template>
<script setup lang="uts">
import { defineProps } from 'vue'
const props = withDefaults(defineProps<{
label: string
required?: boolean
error?: string
}>(), {
required: false,
})
</script>
<style scoped lang="scss">
.form-item {
display: flex;
flex-direction: column;
gap: $space-sm;
}
.form-label {
font-size: $font-size-sm;
color: $text-primary;
font-weight: $font-weight-medium;
}
.required-mark {
color: $error-color;
margin-right: $space-xs;
}
.form-error {
font-size: $font-size-xs;
color: $error-color;
}
</style>
5. 数据展示组件规范
5.1 Table(表格)
核心要点:
- 支持排序和筛选
- 支持行选择
- 支持固定列头
- 支持虚拟滚动
<template>
<view class="table-wrapper">
<view class="table-header">
<view class="table-row">
<view v-for="col in columns" :key="col.key" class="table-cell" :style="{ width: col.width }">
<text class="table-header-text">{{ col.title }}</text>
</view>
</view>
</view>
<view class="table-body">
<view v-for="row in data" :key="row.id" class="table-row">
<view v-for="col in columns" :key="col.key" class="table-cell" :style="{ width: col.width }">
<slot :name="`cell-${col.key}`" :row="row">
{{ row[col.key] }}
</slot>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { defineProps } from 'vue'
const props = defineProps<{
columns: Array<{
key: string
title: string
width?: string
}>
data: Array<Record<string, any>>
}>()
</script>
<style scoped lang="scss">
.table-wrapper {
border: 1px solid $border-light;
border-radius: $radius;
overflow: hidden;
}
.table-header {
background: $background-secondary;
border-bottom: 1px solid $border-light;
}
.table-row {
display: flex;
align-items: stretch;
}
.table-cell {
padding: $space-md;
border-right: 1px solid $border-light;
flex: 1;
&:last-child {
border-right: none;
}
}
.table-header-text {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.table-body {
.table-row {
border-bottom: 1px solid $border-light;
&:hover {
background: $background-secondary;
}
&:last-child {
border-bottom: none;
}
}
.table-cell {
color: $text-primary;
font-size: $font-size;
}
}
</style>
6. 反馈组件规范
6.1 Message(消息提示)
<script setup lang="uts">
import { ref, computed } from 'vue'
export interface MessageOptions {
type: 'info' | 'success' | 'warning' | 'error'
content: string
duration?: number
}
const messages = ref<Array<MessageOptions & { id: string }>>([])
const show = (options: MessageOptions) => {
const id = Date.now().toString()
const message = { ...options, id }
messages.value.push(message)
if (options.duration !== 0) {
setTimeout(() => {
messages.value = messages.value.filter(m => m.id !== id)
}, options.duration || 3000)
}
return {
close: () => {
messages.value = messages.value.filter(m => m.id !== id)
},
}
}
// 导出方法供全局使用
defineExpose({
show,
})
</script>
<template>
<view class="messages">
<view
v-for="msg in messages"
:key="msg.id"
:class="['message', `message-${msg.type}`]"
>
{{ msg.content }}
</view>
</view>
</template>
<style scoped lang="scss">
.messages {
position: fixed;
top: $space-lg;
right: $space-lg;
z-index: $z-index-notification;
display: flex;
flex-direction: column;
gap: $space-md;
}
.message {
padding: $space-md;
border-radius: $radius;
color: #fff;
font-size: $font-size-sm;
box-shadow: $shadow-lg;
animation: slideIn 0.3s ease-out;
&.message-info {
background: $primary-color;
}
&.message-success {
background: $success-color;
}
&.message-warning {
background: $warning-color;
}
&.message-error {
background: $error-color;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
7. 组件命名规范
7.1 文件命名
- 使用 PascalCase(帕斯卡命名法)
- 例如:
Button.uvue,FormItem.uvue,TableCell.uvue
7.2 Props 命名
- 使用 camelCase(驼峰命名法)
- 例如:
modelValue,isLoading,onConfirm
7.3 事件命名
- 使用 camelCase(驼峰命名法)
- 例如:
@click,@change,@submit
7.4 Slot 命名
- 使用 kebab-case(短横线命名法)
- 例如:
<slot name="header" />,<slot name="footer" />
7.5 Class 命名
- 使用 kebab-case(短横线命名法)
- 例如:
.form-item,.btn-primary,.list-item
8. Props 和 Emit 规范
8.1 Props 验证
interface ButtonProps {
type?: "primary" | "default" | "danger" | "success";
size?: "sm" | "md" | "lg";
label: string;
disabled?: boolean;
loading?: boolean;
}
const props = withDefaults(defineProps<ButtonProps>(), {
type: "primary",
size: "md",
disabled: false,
loading: false,
});
8.2 Emit 验证
const emit = defineEmits<{
click: [];
change: [value: string];
submit: [formData: Record<string, any>];
}>();
9. 组件文档模板
每个组件都应包含以下文档:
# Button 组件
## 描述
按钮组件,支持多种类型和尺寸。
## Props
| 属性 | 类型 | 默认值 | 说明 |
| -------- | ------- | ------- | ---------------------------------------- |
| type | string | primary | 按钮类型: primary/default/danger/success |
| size | string | md | 按钮尺寸: sm/md/lg |
| label | string | - | 按钮文本 |
| disabled | boolean | false | 是否禁用 |
| loading | boolean | false | 是否加载中 |
## Events
| 事件 | 参数 | 说明 |
| ----- | ---- | -------- |
| click | - | 点击事件 |
## Slots
无
## 示例
\`\`\`vue
<Button type="primary" label="创建" @click="handleCreate" />
\`\`\`
10. 组件开发清单
在开发新组件前,确保已完成以下事项:
- 组件放在正确的分类目录
- 组件名称遵循 PascalCase
- Props 已定义 TypeScript 接口
- Emit 事件已定义类型
- 样式变量全部来自 uni.scss
- 支持响应式设计
- 提供了完整的使用示例
- 编写了 JSDoc 文档
- 测试通过(所有状态)
- 无 console.log 和调试代码
11. 常见模式
11.1 v-model 支持
// Props
const props = defineProps<{
modelValue?: string;
}>();
// Emit
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
// 使用
const handleChange = (value: string) => {
emit("update:modelValue", value);
};
11.2 插槽使用
<template>
<view class="component">
<slot name="header" />
<slot /> <!-- 默认插槽 -->
<slot name="footer" />
</view>
</template>
<!-- 使用 -->
<Component>
<template #header>
<text>头部</text>
</template>
<text>内容</text>
<template #footer>
<text>底部</text>
</template>
</Component>
11.3 条件渲染
<view v-if="visible" class="component">
<!-- 内容 -->
</view>
<view v-show="visible" class="component">
<!-- 使用 v-show 用于频繁显隐,性能更好 -->
</view>
总结
✅ 组件开发核心原则:
- 单一职责 - 每个组件只做一件事
- 可复用性 - 组件应该能在多个场景使用
- 灵活性 - 提供足够的 Props 和 Slots 来自定义
- 可维护性 - 清晰的代码结构和充分的文档
- 性能 - 避免不必要的渲染和内存泄漏
❌ 禁止做法:
- 在组件中混合业务逻辑
- 创建过度抽象的组件
- 使用硬编码的样式值
- 忽视响应式设计
- 没有文档的组件
文档版本: 1.0
最后更新: 2026-01-31
维护者: AI Assistant