【Vue3+Vite+TS】13.0 组件十:强大的表单组件(上)

功能

    1. 可配置型表单,通过json对象的方式自动生成表单
    1. 具备更完善的功能:表单验证、自定义验证规则、动态删减表单、集成第三方的插件
    1. 用法简单,扩展性强,可维护性强
    1. 能够用在更多的场景,比如弹框嵌套表单

准备工作

  1. 分析element-plus表单能够在那些方面做优化
  2. 完善封装表单的类型,支持ts
  3. 封装的表单要具备element-plus原表单的所有功能
  4. 集成第三方插件:markdown编辑器、富文本编辑器(
    比如WangEditor)

必备UI组件

将用到的组件:
很多,涉及到表单的所有组件。
第三方组件:WangEditor

组件设计

新建src\components\baseline\form\index.ts

import { App } from 'vue'
import Form from './src/index.vue'

export { Form }

//组件可通过use的形式使用
export default {
  Form,
    install(app: App) {
        app.component('bs-form', Form)
    },
}

调整src\components\baseline\index.ts

import { App } from 'vue'
import ChooseArea from './chooseArea'
import ChooseIcon from './chooseIcon'
import Container from './container'
import Trend from './trend'
import Notification from './notification'
import List from './list'
import Menu from './menu'
import Progress from './progress'
import ChooseTime from './chooseTime'
import ChooseDate from './chooseDate'
import ChooseCity from './chooseCity'
import Form from './form'
const components = [
    ChooseArea,
    ChooseIcon,
    Container,
    Trend,
    Notification,
    List,
    Menu,
    Progress,
    ChooseTime,
    ChooseDate,
    ChooseCity,
    Form,
]
export {
    ChooseArea,
    ChooseIcon,
    Container,
    Trend,
    Notification,
    List,
    Menu,
    Progress,
    ChooseTime,
    ChooseDate,
    ChooseCity,
    Form,
}

//组件可通过use的形式使用
export default {
    install(app: App) {
        components.map(item => {
            app.use(item)
        })
    },
    ChooseArea,
    ChooseIcon,
    Container,
    Trend,
    Notification,
    List,
    Menu,
    Progress,
    ChooseTime,
    ChooseDate,
    ChooseCity,
    Form,
}

路由增加,调整src\router\index.ts

   {
                path: '/form',
                component: () => import('../views/baseline/form/index.vue'),
            },

新增src\views\baseline\form\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
        },
    },
]
</script>
<style lang="scss" scoped></style>

到此,基本结构搭建完毕。

如果需要做到表单通过json自动配置组合,需要建立一套完整的ts类型限制。
新建src\components\baseline\form\src\types\types.ts

import { CSSProperties } from 'vue'
import { RuleItem } from './rule'

import { ValidateFieldsError } from 'async-validator'
interface Callback {
    (isValid?: boolean, invalidFields?: ValidateFieldsError): void
}

/**
 * 表单每一项的配置选项
 */
export interface FormOptions {
    // 表单项显示的元素
    type:
        | 'cascader'//级联选择器
        | 'checkbox'//多选框
        | 'checkbox-group'
        | 'checkbox-button'
        | 'color-picker'
        | 'date-picker'
        | 'input'
        | 'input-number'
        | 'radio'
        | 'radio-group'
        | 'radio-button'
        | 'rate'
        | 'select'
        | 'option'
        | 'slider'
        | 'switch'
        | 'time-picker'
        | 'time-select'
        | 'transfer'//穿梭框
        | 'upload'
        | 'editor'
    // 表单项的值
    value?: any
    // 表单项label
    label?: string
    // 表单项的标识
    prop?: string
    // 表单项的验证规则
    rules?: RuleItem[]//基于async-validator规则验证
    // 表单项的占位符
    placeholder?: string
    // 表单元素特有的属性
    attrs?: {
        // css样式
        style?: CSSProperties
        clearable?: boolean
        showPassword?: boolean
        disabled?: boolean
    }
    // 表单项的子元素
    children?: FormOptions[]
    // 处理上传组件的属性和方法
    uploadAttrs?: {
        action: string
        headers?: object
        method?: 'post' | 'put' | 'patch'
        multiple?: boolean
        data?: any
        name?: string
        withCredentials?: boolean
        showFileList?: boolean
        drag?: boolean
        accept?: string
        thumbnailMode?: boolean
        fileList?: any[]
        listType?: 'text' | 'picture' | 'picture-card'
        autoUpload?: boolean
        disabled?: boolean
        limit?: number
    }
}

export interface ValidateFieldCallback {
    (message?: string, invalidFields?: ValidateFieldsError): void
}

export interface FormInstance {
    registerLabelWidth(width: number, oldWidth: number): void
    deregisterLabelWidth(width: number): void
    autoLabelWidth: string | undefined
    emit: (evt: string, ...args: any[]) => void
    labelSuffix: string
    inline?: boolean
    model?: Record<string, unknown>
    size?: string
    showMessage?: boolean
    labelPosition?: string
    labelWidth?: string
    rules?: Record<string, unknown>
    statusIcon?: boolean
    hideRequiredAsterisk?: boolean
    disabled?: boolean
    validate: (callback?: Callback) => Promise<boolean>
    resetFields: () => void
    clearValidate: (props?: string | string[]) => void
    validateField: (props: string | string[], cb: ValidateFieldCallback) => void
}

新建src\components\baseline\form\src\types\rule.ts
该文件从async-validator规则验证github项目中复制出来的,不需要自己思考文件内容。

export type RuleType =
  | 'string'
  | 'number'
  | 'boolean'
  | 'method'
  | 'regexp'
  | 'integer'
  | 'float'
  | 'array'
  | 'object'
  | 'enum'
  | 'date'
  | 'url'
  | 'hex'
  | 'email'
  | 'pattern'
  | 'any';

export interface ValidateOption {
  // whether to suppress internal warning
  suppressWarning?: boolean;

  // when the first validation rule generates an error stop processed
  first?: boolean;

  // when the first validation rule of the specified field generates an error stop the field processed, 'true' means all fields.
  firstFields?: boolean | string[];

  messages?: Partial<ValidateMessages>;

  /** The name of rules need to be trigger. Will validate all rules if leave empty */
  keys?: string[];

  error?: (rule: InternalRuleItem, message: string) => ValidateError;
}

export type SyncErrorType = Error | string;
export type SyncValidateResult = boolean | SyncErrorType | SyncErrorType[];
export type ValidateResult = void | Promise<void> | SyncValidateResult;

export interface RuleItem {
  type?: RuleType; // default type is 'string'
  required?: boolean;
  pattern?: RegExp | string;
  min?: number; // Range of type 'string' and 'array'
  max?: number; // Range of type 'string' and 'array'
  len?: number; // Length of type 'string' and 'array'
  enum?: Array<string | number | boolean | null | undefined>; // possible values of type 'enum'
  whitespace?: boolean;
  trigger?: string | string[];
  fields?: Record<string, Rule>; // ignore when without required
  options?: ValidateOption;
  defaultField?: Rule; // 'object' or 'array' containing validation rules
  transform?: (value: Value) => Value;
  message?: string | ((a?: string) => string);
  asyncValidator?: (
    rule: InternalRuleItem,
    value: Value,
    callback: (error?: string | Error) => void,
    source: Values,
    options: ValidateOption,
  ) => void | Promise<void>;
  validator?: (
    rule: InternalRuleItem,
    value: Value,
    callback: (error?: string | Error) => void,
    source: Values,
    options: ValidateOption,
  ) => SyncValidateResult | void;
}

export type Rule = RuleItem | RuleItem[];

export type Rules = Record<string, Rule>;

/**
 *  Rule for validating a value exists in an enumerable list.
 *
 *  @param rule The validation rule.
 *  @param value The value of the field on the source object.
 *  @param source The source object being validated.
 *  @param errors An array of errors that this rule may add
 *  validation errors to.
 *  @param options The validation options.
 *  @param options.messages The validation messages.
 *  @param type Rule type
 */
export type ExecuteRule = (
  rule: InternalRuleItem,
  value: Value,
  source: Values,
  errors: string[],
  options: ValidateOption,
  type?: string,
) => void;

/**
 *  Performs validation for any type.
 *
 *  @param rule The validation rule.
 *  @param value The value of the field on the source object.
 *  @param callback The callback function.
 *  @param source The source object being validated.
 *  @param options The validation options.
 *  @param options.messages The validation messages.
 */
export type ExecuteValidator = (
  rule: InternalRuleItem,
  value: Value,
  callback: (error?: string[]) => void,
  source: Values,
  options: ValidateOption,
) => void;

// >>>>> Message
type ValidateMessage<T extends any[] = unknown[]> =
  | string
  | ((...args: T) => string);
type FullField = string | undefined;
type EnumString = string | undefined;
type Pattern = string | RegExp | undefined;
type Range = number | undefined;
type Type = string | undefined;

export interface ValidateMessages {
  default?: ValidateMessage;
  required?: ValidateMessage<[FullField]>;
  enum?: ValidateMessage<[FullField, EnumString]>;
  whitespace?: ValidateMessage<[FullField]>;
  date?: {
    format?: ValidateMessage;
    parse?: ValidateMessage;
    invalid?: ValidateMessage;
  };
  types?: {
    string?: ValidateMessage<[FullField, Type]>;
    method?: ValidateMessage<[FullField, Type]>;
    array?: ValidateMessage<[FullField, Type]>;
    object?: ValidateMessage<[FullField, Type]>;
    number?: ValidateMessage<[FullField, Type]>;
    date?: ValidateMessage<[FullField, Type]>;
    boolean?: ValidateMessage<[FullField, Type]>;
    integer?: ValidateMessage<[FullField, Type]>;
    float?: ValidateMessage<[FullField, Type]>;
    regexp?: ValidateMessage<[FullField, Type]>;
    email?: ValidateMessage<[FullField, Type]>;
    url?: ValidateMessage<[FullField, Type]>;
    hex?: ValidateMessage<[FullField, Type]>;
  };
  string?: {
    len?: ValidateMessage<[FullField, Range]>;
    min?: ValidateMessage<[FullField, Range]>;
    max?: ValidateMessage<[FullField, Range]>;
    range?: ValidateMessage<[FullField, Range, Range]>;
  };
  number?: {
    len?: ValidateMessage<[FullField, Range]>;
    min?: ValidateMessage<[FullField, Range]>;
    max?: ValidateMessage<[FullField, Range]>;
    range?: ValidateMessage<[FullField, Range, Range]>;
  };
  array?: {
    len?: ValidateMessage<[FullField, Range]>;
    min?: ValidateMessage<[FullField, Range]>;
    max?: ValidateMessage<[FullField, Range]>;
    range?: ValidateMessage<[FullField, Range, Range]>;
  };
  pattern?: {
    mismatch?: ValidateMessage<[FullField, Value, Pattern]>;
  };
}

export interface InternalValidateMessages extends ValidateMessages {
  clone: () => InternalValidateMessages;
}

// >>>>> Values
export type Value = any;
export type Values = Record<string, Value>;

// >>>>> Validate
export interface ValidateError {
  message?: string;
  fieldValue?: Value;
  field?: string;
}

export type ValidateFieldsError = Record<string, ValidateError[]>;

export type ValidateCallback = (
  errors: ValidateError[] | null,
  fields: ValidateFieldsError | Values,
) => void;

export interface RuleValuePackage {
  rule: InternalRuleItem;
  value: Value;
  source: Values;
  field: string;
}

export interface InternalRuleItem extends Omit<RuleItem, 'validator'> {
  field?: string;
  fullField?: string;
  fullFields?: string[];
  validator?: RuleItem['validator'] | ExecuteValidator;
}

组件完善

新建src\components\baseline\form\src\index.vue

<template>
    <div>
        <el-form>
            <el-form-item v-for="(item, index) in options" :key="index">
                <component :is="`el-${item.type}`"></component>
            </el-form-item>
        </el-form>
    </div>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { FormOptions } from './types/types'
let props = defineProps({
    options: {
        type: Array as PropType<FormOptions[]>,
        required: true,
    },
})
</script>
<style lang="scss" scoped></style>

运行效果如下:


image.png

优化:

            <el-form-item
                :label="item.label"
                v-for="(item, index) in options"
                :key="index"
            >
                <component :is="`el-${item.type}`"></component>
            </el-form-item>

效果如下:


image.png

表单本身属性扩展:

        <el-form v-bind="$attrs">
            <el-form-item
                :label="item.label"
                v-for="(item, index) in options"
                :key="index"
            >
                <component :is="`el-${item.type}`"></component>
            </el-form-item>
        </el-form>

修改src\views\baseline\form\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form label-width="1rem" :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
        },
    },
    {
        type: 'input',
        value: '',
        label: '密码',
        rules: [
            {
                required: true,
                message: '密码不能为空',
                trigger: 'blur',
            },
            {
                min: 6,
                max: 20,
                message: '密码长度在6-20位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
        },
    },
]
</script>
<style lang="scss" scoped></style>

效果如下:


image.png

这里需要用到深拷贝,建议使用一个很好地第三方js工具库:

 npm i -S lodash @types/lodash

优化src\components\baseline\form\src\index.vue

<template>
    <div>
        <!-- validate-on-rule-change="false"不需要一进来就验证 -->
        <el-form
            :validate-on-rule-change="false"
            :model="model"
            :rules="rules"
            v-bind="$attrs"
        >
            <el-form-item
                :prop="item.prop"
                :label="item.label"
                v-for="(item, index) in options"
                :key="index"
            >
                <component
                    v-bind="item.attrs"
                    :is="`el-${item.type}`"
                    v-model="model[item.prop!]"
                ></component>
            </el-form-item>
        </el-form>
    </div>
</template>
<script lang="ts" setup>
import { PropType, ref, onMounted } from 'vue'
import { FormOptions } from './types/types'
let props = defineProps({
    options: {
        type: Array as PropType<FormOptions[]>,
        required: true,
    },
})
//局部引入,深拷贝
import cloneDeep from 'lodash/cloneDeep'

const model = ref<any>({})
const rules = ref<any>({})

onMounted(() => {
    let m: any = {}
    let r: any = {}
    props.options.map((item: FormOptions) => {
        m[item.prop!] = item.value
        r[item.prop!] = item.rules
    })
    model.value = cloneDeep(m)
    rules.value = cloneDeep(r)
    console.log('model', model.value)
    console.log('rules', rules.value)
})
</script>
<style lang="scss" scoped></style>

调整src\views\baseline\form\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form label-width="1rem" :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        prop: 'username',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            clearable: true,
        },
    },
    {
        type: 'input',
        value: '',
        label: '密码',
        prop: 'password',
        rules: [
            {
                required: true,
                message: '密码不能为空',
                trigger: 'blur',
            },
            {
                min: 6,
                max: 20,
                message: '密码长度在6-20位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
            clearable: true,
        },
    },
]
</script>
<style lang="scss" scoped></style>

效果基本完成:


image.png

子元素组件

像select,是存在option子元素的,调整如下:
修改src\views\baseline\form\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form label-width="1rem" :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        prop: 'username',
        placeholder: '请输入用户名',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            clearable: true,
        },
    },
    {
        type: 'input',
        value: '',
        label: '密码',
        prop: 'password',
        placeholder: '请输入6-20位密码',
        rules: [
            {
                required: true,
                message: '密码不能为空',
                trigger: 'blur',
            },
            {
                min: 6,
                max: 20,
                message: '密码长度在6-20位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
            clearable: true,
        },
    },
    {
        type: 'select',
        value: '1',//初始化表单数据
        label: '职位',
        prop: 'role',
        placeholder: '请选择职位',
        rules: [
            {
                required: true,
                message: '职位不能为空',
                trigger: 'blur',
            },
        ],
        children: [
            { type: 'option', label: '经理', value: '1' },
            { type: 'option', label: '主管', value: '2' },
            { type: 'option', label: '员工', value: '3' },
        ],
    },
]
</script>
<style lang="scss" scoped></style>

修改src\components\baseline\form\src\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form label-width="1rem" :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        prop: 'username',
        placeholder: '请输入用户名',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            clearable: true,
        },
    },
    {
        type: 'input',
        value: '',
        label: '密码',
        prop: 'password',
        placeholder: '请输入6-20位密码',
        rules: [
            {
                required: true,
                message: '密码不能为空',
                trigger: 'blur',
            },
            {
                min: 6,
                max: 20,
                message: '密码长度在6-20位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
            clearable: true,
        },
    },
    {
        type: 'select',
        value: '1',//初始化表单数据
        label: '职位',
        prop: 'role',
        placeholder: '请选择职位',
        rules: [
            {
                required: true,
                message: '职位不能为空',
                trigger: 'blur',
            },
        ],
        children: [
            { type: 'option', label: '经理', value: '1' },
            { type: 'option', label: '主管', value: '2' },
            { type: 'option', label: '员工', value: '3' },
        ],
    },
]
</script>
<style lang="scss" scoped></style>

效果如下:


image.png

style样式增加

修改src\views\baseline\form\index.vue

<template>
    <div class="bs-wrapper">
        <bs-form label-width="1rem" :options="options"></bs-form>
    </div>
</template>
<script lang="ts" setup>
import { FormOptions } from '@/components/baseline/form/src/types/types'

let options: FormOptions[] = [
    {
        type: 'input',
        value: '',
        label: '用户名',
        prop: 'username',
        placeholder: '请输入用户名',
        rules: [
            {
                required: true,
                message: '用户名不能为空',
                trigger: 'blur',
            },
            {
                min: 2,
                max: 10,
                message: '用户名长度在2-10位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            clearable: true,
        },
    },
    {
        type: 'input',
        value: '',
        label: '密码',
        prop: 'password',
        placeholder: '请输入6-20位密码',
        rules: [
            {
                required: true,
                message: '密码不能为空',
                trigger: 'blur',
            },
            {
                min: 6,
                max: 20,
                message: '密码长度在6-20位之间',
                trigger: 'blur',
            },
        ],
        attrs: {
            showPassword: true,
            clearable: true,
        },
    },
    {
        type: 'select',
        value: '1', //初始化表单数据
        label: '职位',
        prop: 'role',
        placeholder: '请选择职位',
        rules: [
            {
                required: true,
                message: '职位不能为空',
                trigger: 'blur',
            },
        ],
        children: [
            { type: 'option', label: '经理', value: '1' },
            { type: 'option', label: '主管', value: '2' },
            { type: 'option', label: '员工', value: '3' },
        ],
        attrs: {
            style: {
                width: '100%',
            },
        },
    },
    {
        type: 'checkbox-group',
        value: [],
        prop: 'like',
        label: '爱好',
        rules: [
            {
                required: true,
                message: '爱好不能为空',
                trigger: 'blur',
            },
        ],
        children: [
            {
                type: 'checkbox',
                label: '足球',
                value: '1',
            },
            {
                type: 'checkbox',
                label: '篮球',
                value: '1',
            },
            {
                type: 'checkbox',
                label: '乒乓球',
                value: '3',
            },
        ],
    },
    {
        type: 'radio-group',
        value: '',
        prop: 'gender',
        label: '性别',
        rules: [
            {
                required: true,
                message: '性别不能为空',
                trigger: 'blur',
            },
        ],
        children: [
            {
                type: 'radio',
                label: '男',
                value: '1',
            },
            {
                type: 'radio',
                label: '女',
                value: '2',
            },
            {
                type: 'radio',
                label: '保密',
                value: '3',
            },
        ],
    },
]
</script>
<style lang="scss" scoped></style>

修改src\components\baseline\form\src\index.vue

<template>
    <div>
        <!-- validate-on-rule-change="false"不需要一进来就验证 -->
        <el-form
            ref="form"
            v-if="model"
            :validate-on-rule-change="false"
            :model="model"
            :rules="rules"
            v-bind="$attrs"
        >
            <template v-for="(item, index) in options" :key="index">
                <el-form-item
                    :prop="item.prop"
                    :label="item.label"
                    v-if="!item.children || !item.children!.length"
                >
                    <component
                        :placeholder="item.placeholder"
                        v-bind="item.attrs"
                        :is="`el-${item.type}`"
                        v-model="model[item.prop!]"
                    ></component>
                </el-form-item>
                <el-form-item
                    :prop="item.prop"
                    :label="item.label"
                    v-if="item.children && item.children.length"
                >
                    <component
                        v-bind="item.attrs"
                        :placeholder="item.placeholder"
                        :is="`el-${item.type}`"
                        v-model="model[item.prop!]"
                    >
                        <component
                            v-for="(child, i) in item.children"
                            :key="i"
                            :label="child.label"
                            :value="child.value"
                            :is="`el-${child.type}`"
                        ></component>
                    </component>
                </el-form-item>
            </template>
        </el-form>
    </div>
</template>
<script lang="ts" setup>
import { PropType, ref, onMounted, watch } from 'vue'
import { FormOptions } from './types/types'
let props = defineProps({
    options: {
        type: Array as PropType<FormOptions[]>,
        required: true,
    },
})
//局部引入,深拷贝
import cloneDeep from 'lodash/cloneDeep'

const model = ref<any>()
const rules = ref<any>()

const initForm = () => {
    let m: any = {}
    let r: any = {}
    props.options.map((item: FormOptions) => {
        m[item.prop!] = item.value
        r[item.prop!] = item.rules
    })
    model.value = cloneDeep(m)
    rules.value = cloneDeep(r)
    console.log('model', model.value)
    console.log('rules', rules.value)
}

onMounted(() => {
    if (props.options && props.options.length) {
        initForm()
    }
})
//监听父组件传递进来的options的变化
watch(
    () => props.options,
    val => {
        initForm()
    },
    { deep: true }
)
</script>
<style lang="scss" scoped></style>

效果如下:


image.png

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):