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

23 KiB
Raw Blame History

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>

总结

组件开发核心原则:

  1. 单一职责 - 每个组件只做一件事
  2. 可复用性 - 组件应该能在多个场景使用
  3. 灵活性 - 提供足够的 Props 和 Slots 来自定义
  4. 可维护性 - 清晰的代码结构和充分的文档
  5. 性能 - 避免不必要的渲染和内存泄漏

禁止做法:

  • 在组件中混合业务逻辑
  • 创建过度抽象的组件
  • 使用硬编码的样式值
  • 忽视响应式设计
  • 没有文档的组件

文档版本: 1.0
最后更新: 2026-01-31
维护者: AI Assistant