Skip to content

表单组件标准示例 — Article

源文件:admin/src/views/content/article/components/ArticleForm.vue(生成代码必须模仿本示例的结构与风格)

本示例为手写风格范本,用于学习结构与分层;经 make:crud 生成的骨架方法名以骨架为准,做业务增量时不要为对齐本示例而改名。

完整代码

vue
<template>
    <el-drawer
        v-model="visible"
        :title="form.id ? t('articleMgmt.editArticle') : t('articleMgmt.addArticle')"
        size="60%"
        :close-on-click-modal="false"
        @closed="resetForm"
    >
        <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
            <el-form-item :label="t('articleMgmt.articleTitle')" prop="title">
                <el-input
                    v-model="form.title"
                    :placeholder="t('articleMgmt.titlePlaceholder')"
                    maxlength="200"
                    show-word-limit
                />
            </el-form-item>

            <el-form-item :label="t('articleMgmt.category')" prop="category_id">
                <el-tree-select
                    v-model="form.category_id"
                    :data="categoryTreeData"
                    node-key="id"
                    :props="{ label: 'name' }"
                    :placeholder="t('articleMgmt.selectCategory')"
                    check-strictly
                    clearable
                    style="width: 100%"
                />
            </el-form-item>

            <el-form-item :label="t('articleMgmt.cover')" prop="cover">
                <div>
                    <el-upload
                        class="cover-uploader"
                        :show-file-list="false"
                        action="/adminapi/upload/image"
                        :headers="uploadHeaders"
                        :on-success="handleCoverSuccess"
                        :before-upload="beforeCoverUpload"
                    >
                        <img
                            v-if="form.cover"
                            :src="appStore.getImageUrl(form.cover)"
                            class="cover-image"
                            :alt="t('articleMgmt.coverAlt')"
                        />
                        <el-icon v-else class="cover-uploader-icon"><Plus /></el-icon>
                    </el-upload>
                    <div class="upload-tip">{{ t('articleMgmt.coverUploadTip') }}</div>
                </div>
            </el-form-item>

            <el-form-item :label="t('articleMgmt.summary')" prop="summary">
                <el-input
                    v-model="form.summary"
                    type="textarea"
                    :rows="3"
                    :placeholder="t('articleMgmt.summaryPlaceholder')"
                    maxlength="500"
                    show-word-limit
                />
            </el-form-item>

            <el-form-item :label="t('articleMgmt.content')" prop="content">
                <WangEditor v-model="form.content" :height="400" />
            </el-form-item>

            <el-form-item :label="t('articleMgmt.tags')" prop="tags">
                <div class="tags-container">
                    <el-tag
                        v-for="tag in form.tags"
                        :key="tag"
                        closable
                        :disable-transitions="false"
                        @close="handleTagRemove(tag)"
                    >
                        {{ tag }}
                    </el-tag>
                    <el-input
                        v-if="tagInputVisible"
                        ref="tagInputRef"
                        v-model="tagInputValue"
                        size="small"
                        style="width: 100px"
                        @keyup.enter="handleTagConfirm"
                        @blur="handleTagConfirm"
                    />
                    <el-button v-else size="small" @click="showTagInput">
                        + {{ t('articleMgmt.addTag') }}
                    </el-button>
                </div>
            </el-form-item>

            <el-row :gutter="20">
                <el-col :span="12">
                    <el-form-item :label="t('articleMgmt.author')" prop="author">
                        <el-input
                            v-model="form.author"
                            :placeholder="t('articleMgmt.authorPlaceholder')"
                        />
                    </el-form-item>
                </el-col>
                <el-col :span="12">
                    <el-form-item :label="t('articleMgmt.publishAt')" prop="publish_at">
                        <el-date-picker
                            v-model="form.publish_at"
                            type="datetime"
                            :placeholder="t('articleMgmt.publishTimePlaceholder')"
                            value-format="YYYY-MM-DD HH:mm:ss"
                            style="width: 100%"
                        />
                    </el-form-item>
                </el-col>
            </el-row>

            <el-form-item :label="t('common.status')" prop="status">
                <el-radio-group v-model="form.status">
                    <el-radio :value="0">{{ t('articleMgmt.draft') }}</el-radio>
                    <el-radio :value="1">{{ t('articleMgmt.published') }}</el-radio>
                </el-radio-group>
            </el-form-item>
        </el-form>

        <template #footer>
            <el-button @click="handleClose">{{ t('common.cancel') }}</el-button>
            <el-button type="primary" :loading="submitting" @click="handleSubmit">{{
                t('common.confirm')
            }}</el-button>
        </template>
    </el-drawer>
</template>

<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
import type { FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import { articleApi } from '@/api/article'
import WangEditor from '@/components/WangEditor/index.vue'
import { useFormDialog } from '@/hooks/useFormDialog'
import { useAppStore } from '@/store'
import { getToken } from '@/utils/auth'

const { t } = useI18n()
const appStore = useAppStore()

interface ArticleFormData {
    id?: number
    title: string
    category_id?: number
    cover: string
    summary: string
    content: string
    tags: string[]
    author: string
    status: number
    publish_at: string
}

const props = defineProps<{
    modelValue: boolean
    formData: Record<string, any>
    categoryOptions: any[]
}>()

const emit = defineEmits<{
    'update:modelValue': [value: boolean]
    success: []
}>()

// 上传请求头(computed 确保 Token 刷新后仍有效)
const uploadHeaders = computed(() => ({
    Authorization: `Bearer ${getToken()}`
}))

const { form, formRef, submitting, visible, handleSubmit, handleClose, resetForm } =
    useFormDialog<ArticleFormData>({
        defaultForm: {
            id: undefined,
            title: '',
            category_id: undefined,
            cover: '',
            summary: '',
            content: '',
            tags: [],
            author: '',
            status: 0,
            publish_at: ''
        },
        modelValue: () => props.modelValue,
        onUpdate: (v) => emit('update:modelValue', v),
        onSuccess: () => emit('success'),
        createFn: (data) => articleApi.create(data),
        updateFn: (id, data) => articleApi.update(id, data),
        sourceData: () => props.formData as Partial<ArticleFormData>
    })

// 标签输入相关
const tagInputVisible = ref(false)
const tagInputValue = ref('')
const tagInputRef = ref<InstanceType<(typeof import('element-plus'))['ElInput']>>()

// 分类树形数据
const categoryTreeData = computed(() => {
    return props.categoryOptions || []
})

const rules = computed<FormRules>(() => ({
    title: [{ required: true, message: t('articleMgmt.validate.titleRequired'), trigger: 'blur' }]
}))

// 封面上传成功
const handleCoverSuccess = (response: any) => {
    if (response.code === 200 || response.code === 0) {
        form.cover = response.data?.url || response.data?.path || response.data
        ElMessage.success(t('articleMgmt.coverUploadSuccess'))
    } else {
        ElMessage.error(response.message || t('articleMgmt.uploadFailed'))
    }
}

// 封面上传前校验
const beforeCoverUpload = (file: File) => {
    const isImage = file.type.startsWith('image/')
    if (!isImage) {
        ElMessage.error(t('articleMgmt.onlyImageAllowed'))
        return false
    }
    const isLt2M = file.size / 1024 / 1024 < 2
    if (!isLt2M) {
        ElMessage.error(t('articleMgmt.imageSizeLimit'))
        return false
    }
    return true
}

// 标签操作
const handleTagRemove = (tag: string) => {
    form.tags = form.tags.filter((t) => t !== tag)
}

const showTagInput = () => {
    tagInputVisible.value = true
    nextTick(() => {
        tagInputRef.value?.input?.focus()
    })
}

const handleTagConfirm = () => {
    if (tagInputValue.value) {
        const val = tagInputValue.value.trim()
        if (val && !form.tags.includes(val)) {
            form.tags.push(val)
        }
    }
    tagInputVisible.value = false
    tagInputValue.value = ''
}
</script>

<style lang="scss" scoped>
.cover-uploader {
    :deep(.el-upload) {
        border: 1px dashed var(--color-border);
        border-radius: 6px;
        cursor: pointer;
        position: relative;
        overflow: hidden;
        transition: border-color 0.2s;
        width: 178px;
        height: 120px;
        display: flex;
        align-items: center;
        justify-content: center;

        &:hover {
            border-color: var(--el-color-primary);
        }
    }

    .cover-image {
        width: 178px;
        height: 120px;
        object-fit: cover;
        display: block;
    }

    .cover-uploader-icon {
        font-size: 28px;
        color: var(--color-text-disabled);
    }
}

.upload-tip {
    font-size: 12px;
    color: var(--color-text-tertiary);
    margin-top: 8px;
    line-height: 1.4;
}

.tags-container {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
}
</style>

要点注解

  • 表单组件放在所属模块的 components/ 子目录下(views/content/article/components/ArticleForm.vue),由列表页 index.vue<ArticleForm v-model="formVisible" ... /> 的形式引入,不与列表页平级摆放。
  • 第 9 行 <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">formRefrules 均来自 useFormDialog() 统一封装,校验规则用 computed<FormRules>() 声明(第 211-213 行),便于规则里引用 t() 国际化文案动态求值。
  • 第 179-199 行:新增/编辑表单的显隐、默认值、提交(区分 create/update)、成功回调全部收敛进 useFormDialog<ArticleFormData>() composable,组件内只需提供 defaultFormcreateFnupdateFnsourceData 等配置项,不用手写 submitting/visible 状态和 try/catch 提交逻辑。
  • 若表单被拆分进 <el-tabs> 多个标签页、导致部分字段所在的子表单在未激活 Tab 下未挂载,formRef.value.validate() 前应按 CLAUDE.md「表单验证问题」一节的做法处理:if (formRef.value && typeof formRef.value.validate === 'function') 做条件校验,避免拿到 undefined ref 或跳过必要校验;本表单未分 Tab,formRef 恒定挂载,故未出现该判断。
  • 第 32-52、216-223 行 el-upload 用法:action="/adminapi/upload/image" 直传后端上传接口,:headers="uploadHeaders"(第 175-177 行 computed 包裹 Authorization: Bearer <token>,确保 token 刷新后仍生效)、:on-success="handleCoverSuccess" 从响应中取 data.url/data.path 写回 form.cover:before-upload="beforeCoverUpload" 做图片类型与大小(<2M)前置校验;handleCoverSuccess 中同时兼容 response.code === 200response.code === 0 两种成功码判断,属历史遗留兼容写法,新代码应统一按 CLAUDE.md{ code: 200, ... } 的标准响应格式判断。
  • 第 44 行图片展示通过 appStore.getImageUrl(form.cover) 拼接完整 URL,表单内部存储的是后端返回的相对路径,不在组件里手写域名拼接。
  • 第 69-93 行标签(tags)为动态增删的字符串数组,通过 tagInputVisible 控制输入框显隐、showTagInput()nextTick()tagInputRef.value?.input?.focus() 聚焦,handleTagConfirm() 去重后 push 进 form.tags
  • 第 211-213 行仅对必填字段 title 声明 required 校验规则,category_idcontent 等字段依赖 UI 层(树选择器、富文本编辑器)自身交互约束,未进入 rules,体现「按需最小化校验规则」的写法。

基于 MIT 许可发布