Files
medical-mall/pages/mall/admin/docs/COMPONENT_SPECIFICATION.md
2026-02-03 21:35:57 +08:00

1259 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- 支持禁用和加载状态
- 支持前置和后置图标
#### 代码示例
```uvue
<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>
```
#### 使用示例
```uvue
<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 清空功能
#### 代码示例
```uvue
<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下拉选择
#### 特性
- 支持单选和多选
- 支持搜索过滤
- 支持虚拟滚动(大数据)
- 支持自定义选项模板
#### 示例
```uvue
<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卡片
```uvue
<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模态框
```uvue
<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表单容器
```uvue
<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表单项
```uvue
<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表格
核心要点:
- 支持排序和筛选
- 支持行选择
- 支持固定列头
- 支持虚拟滚动
```uvue
<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消息提示
```uvue
<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 验证
```typescript
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 验证
```typescript
const emit = defineEmits<{
click: [];
change: [value: string];
submit: [formData: Record<string, any>];
}>();
```
---
## 9. 组件文档模板
每个组件都应包含以下文档:
```markdown
# 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 支持
```typescript
// Props
const props = defineProps<{
modelValue?: string;
}>();
// Emit
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
// 使用
const handleChange = (value: string) => {
emit("update:modelValue", value);
};
```
### 11.2 插槽使用
```uvue
<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 条件渲染
```uvue
<view v-if="visible" class="component">
<!-- 内容 -->
</view>
<view v-show="visible" class="component">
<!-- 使用 v-show 用于频繁显隐,性能更好 -->
</view>
```
---
## 总结
**组件开发核心原则**:
1. **单一职责** - 每个组件只做一件事
2. **可复用性** - 组件应该能在多个场景使用
3. **灵活性** - 提供足够的 Props 和 Slots 来自定义
4. **可维护性** - 清晰的代码结构和充分的文档
5. **性能** - 避免不必要的渲染和内存泄漏
**禁止做法**:
- 在组件中混合业务逻辑
- 创建过度抽象的组件
- 使用硬编码的样式值
- 忽视响应式设计
- 没有文档的组件
---
**文档版本**: 1.0
**最后更新**: 2026-01-31
**维护者**: AI Assistant