表单组件标准示例 — 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">:formRef与rules均来自useFormDialog()统一封装,校验规则用computed<FormRules>()声明(第 211-213 行),便于规则里引用t()国际化文案动态求值。 - 第 179-199 行:新增/编辑表单的显隐、默认值、提交(区分 create/update)、成功回调全部收敛进
useFormDialog<ArticleFormData>()composable,组件内只需提供defaultForm、createFn、updateFn、sourceData等配置项,不用手写submitting/visible状态和 try/catch 提交逻辑。 - 若表单被拆分进
<el-tabs>多个标签页、导致部分字段所在的子表单在未激活 Tab 下未挂载,formRef.value.validate()前应按CLAUDE.md「表单验证问题」一节的做法处理:if (formRef.value && typeof formRef.value.validate === 'function')做条件校验,避免拿到undefinedref 或跳过必要校验;本表单未分 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 === 200和response.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_id、content等字段依赖 UI 层(树选择器、富文本编辑器)自身交互约束,未进入rules,体现「按需最小化校验规则」的写法。