Commit 0e5824f6 by lijiabin

【需求 25171】refactor: 发文页按文章类型拆成独立组件

publishLongArticle 之前把专栏/专访/帖子/问吧/实践五种类型的表单全挤在一个文件里,
字段、校验规则、官方账号/管理员身份判断混在一起,改一个类型容易牵连到别的类型。

按类型拆成 components/ 下的 PostForm、ColumnForm、InterviewForm、QuestionForm、
PracticeForm,每个组件独立维护自己完整的抽屉表单(字段、校验、发布时间/IP信息、
提交数据、编辑回显),文件开头写清楚了这个类型的特殊规则(比如帖子封面图什么时候
必填、专栏独有的"同步同事吧"、问吧/实践用图片不用封面图、实践没有推送等)。外层
index.vue 只保留标题/内容编辑器、抽屉壳子和提交/回显的整体编排。

顺带把 api/article/types.ts 里跟实际请求对不上的类型补全了:AddOrUpdatePostDto
之前没声明推送字段,PushSettingBase.pushList 缺了 valueType,新增了
AddOrUpdateQuestionDto。
parent a4df63ff
...@@ -26,11 +26,32 @@ export type AddOrUpdateArticleDto = ...@@ -26,11 +26,32 @@ export type AddOrUpdateArticleDto =
| AddOrUpdateColumnDto | AddOrUpdateColumnDto
| AddOrUpdateInterviewDto | AddOrUpdateInterviewDto
| AddOrUpdateVideoDto | AddOrUpdateVideoDto
| AddOrUpdateQuestionDto
/**
* 推送对象列表里的一项(部门或人员)
*/
export interface PushListItem {
valueId: string
valueType: number
valueName: string | number
}
/**
* 官方账号才有的推送设置字段,帖子/问吧/专栏/专访都共用这一套
*/
interface OfficialPushFields {
isPushAll?: BooleanFlag
pushType?: SendTypeEnum
pushTime?: string
pushList?: PushListItem[]
summary?: string
}
/** /**
* 添加帖子的DTO * 添加帖子的DTO
*/ */
export interface AddOrUpdatePostDto { export interface AddOrUpdatePostDto extends OfficialPushFields {
id?: number id?: number
title?: string title?: string
content?: string content?: string
...@@ -44,6 +65,25 @@ export interface AddOrUpdatePostDto { ...@@ -44,6 +65,25 @@ export interface AddOrUpdatePostDto {
regionHide?: BooleanFlag regionHide?: BooleanFlag
} }
/**
* 添加问吧的DTO
*/
export interface AddOrUpdateQuestionDto extends OfficialPushFields {
id?: number
title?: string
content?: string
releaseStatus?: ReleaseStatusTypeEnum
sendType?: SendTypeEnum
// 问吧封面用内容图片的第一张,faceUrl 是前端自己派生后一起传的
imgUrl?: string
faceUrl?: string
sendTime?: string
type?: ArticleTypeEnum.QUESTION
regionHide?: BooleanFlag
mainTagId?: string
tagList?: { tagId: number; sort: number }[]
}
interface AddOrUpdateColumnBase { interface AddOrUpdateColumnBase {
id?: number id?: number
type: ArticleTypeEnum.COLUMN type: ArticleTypeEnum.COLUMN
...@@ -96,9 +136,10 @@ export interface AddOrUpdateInterviewForm extends AddOrUpdateInterviewBase { ...@@ -96,9 +136,10 @@ export interface AddOrUpdateInterviewForm extends AddOrUpdateInterviewBase {
} }
interface PushSettingBase { interface PushSettingBase {
isPushAll?: BooleanFlag
pushType: SendTypeEnum pushType: SendTypeEnum
pushTime: string pushTime: string
pushList: { valueId: string; valueName: string | number }[] pushList: PushListItem[]
summary: string summary: string
} }
// 推送设置相关的字段 // 推送设置相关的字段
......
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 封面图:官方账号/管理员必填;普通用户选填(这点跟专栏/专访不一样,那两个不管什么身份都必填) -->
<el-form-item label="封面图" prop="faceUrl" :rules="faceUrlRules">
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" :aspect-ratio="3 / 2" crop />
</el-form-item>
<!-- 帖子没有标签、没有关联栏目——只有官方账号才有推送配置 -->
<template v-if="userInfo.isOfficialAccount">
<el-form-item label="推送给">
<div class="w-full flex items-center gap-2">
<el-radio-group v-model="form.isPushAll">
<el-radio :value="BooleanFlag.YES">全员</el-radio>
<el-radio :value="BooleanFlag.NO">指定部门/人员</el-radio>
</el-radio-group>
</div>
<div v-show="form.isPushAll === BooleanFlag.NO" class="flex items-center gap-2">
<DeptUserPicker
v-model:dept-value="pushDeptIds"
v-model:user-value="pushUserIds"
selection-mode="both"
:initial-selected="pushInitialSelected"
@confirm="(items) => (pushSelection = items)"
v-slot="{ echoText }"
>
<el-input
readonly
class="w-80 cursor-pointer"
placeholder="请选择部门/人员"
:model-value="echoText"
/>
</DeptUserPicker>
</div>
</el-form-item>
<el-form-item label="推送类型" prop="pushType">
<el-radio-group v-model="form.pushType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即推送</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时推送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.pushType === SendTypeEnum.SCHEDULED" prop="pushTime">
<el-date-picker
v-model="form.pushTime"
type="datetime"
placeholder="选择推送时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item label="推送摘要" prop="summary">
<el-input
v-model="form.summary"
type="textarea"
:maxlength="50"
show-word-limit
placeholder="简单描述推送的信息"
/>
</el-form-item>
</template>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 帖子(POST)专属表单——自己管自己的整个抽屉表单(字段、校验、提交数据、编辑回显),
* 不依赖外层拆出去的公共表单,改这个类型的规则只用改这一个文件。
*
* 帖子的特殊规则:
* - 封面图:官方账号 / 管理员必填;普通用户选填(专栏、专访不管身份都必填,问吧、实践用的是"图片"不是"封面图")
* - 不分类:没有主/副标签,也没有关联栏目
* - 只有官方账号才看得到"推送"配置(推送给谁 / 推送类型 / 推送摘要),非官方账号完全没有这些字段
* - 没有"插入视频"功能,这个只有专栏、专访的官方账号才有
* - 发布时间、官方账号的 IP 显示设置:所有类型都一样,这里也自己维护一份
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { BooleanFlag, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import DeptUserPicker from '@/components/common/DeptUserPicker/index.vue'
import type { DepartmentUserSelection } from '@/components/common/DepartmentUserModal/types'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
faceUrl: '',
isPushAll: BooleanFlag.YES,
pushType: SendTypeEnum.IMMEDIATE,
pushTime: '',
summary: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
// “推送给”选中的部门/人员:*Ids 是给 DeptUserPicker 用的 id 数组,
// pushSelection 是 @confirm 拿到的完整详情(带名字),提交时直接用它拼 pushList。
const pushDeptIds = ref<string[]>([])
const pushUserIds = ref<string[]>([])
const pushSelection = ref<DepartmentUserSelection[]>([])
// 编辑回显用,给 DeptUserPicker 做首次回显的名字种子。
const pushInitialSelected = ref<DepartmentUserSelection[]>([])
// 时间选择只允许整十分(:00、:10...:50),秒固定为 0
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const faceUrlRules = computed<FormItemRule[]>(() =>
userInfo.value.isOfficialAccount || userInfo.value.isAdmin
? [{ required: true, message: '请上传封面图', trigger: 'blur' }]
: [],
)
const rules: Record<string, FormItemRule[]> = {
pushType: [{ required: true, message: '请选择推送类型', trigger: 'trigger' }],
pushTime: [{ required: true, message: '请选择推送时间', trigger: 'trigger' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
const buildPushList = () =>
form.value.isPushAll === BooleanFlag.YES
? [{ valueId: '1', valueType: 1, valueName: '亚声威格' }]
: pushSelection.value.map((item) => ({
valueId: item.valueId,
valueType: item.valueType,
valueName: item.valueName,
}))
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
faceUrl: form.value.faceUrl,
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount
? {
isPushAll: form.value.isPushAll,
pushType: form.value.pushType,
pushTime: form.value.pushTime,
summary: form.value.summary,
pushList: buildPushList(),
}
: {}),
}),
setEchoData: (data: ArticleItemDto) => {
form.value.faceUrl = data.faceUrl
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (!userInfo.value.isOfficialAccount) return
form.value.summary = data.summary
if (data.pushList?.find((item) => item.valueId == '1')) {
form.value.isPushAll = BooleanFlag.YES
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
pushInitialSelected.value = []
} else {
form.value.isPushAll = BooleanFlag.NO
const items: DepartmentUserSelection[] = (data.pushList || []).map((item) => ({
valueType: item.valueType as 1 | 2,
valueId: String(item.valueId),
valueName: item.valueName,
}))
pushDeptIds.value = items.filter((item) => item.valueType === 1).map((item) => item.valueId)
pushUserIds.value = items.filter((item) => item.valueType === 2).map((item) => item.valueId)
pushSelection.value = items
pushInitialSelected.value = items
}
},
reset: () => {
resetForm()
pushDeptIds.value = []
pushUserIds.value = []
pushSelection.value = []
pushInitialSelected.value = []
},
})
</script>
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<!-- 实践跟问吧一样,用"图片"(内容图)不是"封面图" -->
<el-form-item label="图片" prop="imgUrl">
<UploadFile v-model="form.imgUrl" :limit="1" class="w-full" />
</el-form-item>
<!-- 主/副标签:实践必填 -->
<el-form-item label="主标签" prop="mainTagId">
<SelectTags v-model="form.mainTagId" class="w-full" />
</el-form-item>
<el-form-item label="副标签 (最多3个)">
<SelectTags
v-model="form.tagList"
:filter-tags-fn="filterTagsFn"
:max-selected-tags="3"
class="w-full"
/>
</el-form-item>
<!-- 实践没有推送功能(不管是不是官方账号);官方账号可以选一个所属一级部门,选填 -->
<el-form-item v-if="userInfo.isOfficialAccount" label="所属一级部门">
<el-select v-model="form.deptId" placeholder="请选择一级部门" clearable>
<el-option
v-for="item in firstDepListOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</el-select>
</el-form-item>
<!-- 发布时间 + 官方账号的 IP 信息:所有类型都有,规则完全一样,每个类型自己维护一份 -->
<el-form-item label="发布时间" prop="sendType">
<el-radio-group v-model="form.sendType">
<el-radio :value="SendTypeEnum.IMMEDIATE">立即发布</el-radio>
<el-radio :value="SendTypeEnum.SCHEDULED">定时发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sendType === SendTypeEnum.SCHEDULED" prop="sendTime">
<el-date-picker
v-model="form.sendTime"
type="datetime"
placeholder="选择发布时间"
format="YYYY-MM-DD HH:mm"
time-format="HH:mm"
:disabled-date="(time: Date) => time.getTime() < Date.now() - 1000 * 60 * 60 * 24"
:disabled-minutes="disabledMinutesOnlyTens"
:disabled-seconds="disabledSecondsOnlyZero"
value-format="X"
/>
</el-form-item>
<el-form-item v-if="userInfo.isOfficialAccount" label="IP信息" prop="regionHide">
<el-switch
v-model="form.regionHide"
:active-value="BooleanFlag.YES"
:inactive-value="BooleanFlag.NO"
active-text="隐藏地区"
inactive-text="显示地区"
/>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
/**
* 实践(PRACTICE)专属表单——自己管自己的整个抽屉表单,不依赖外层的公共表单。
*
* 实践的特殊规则:
* - 用"图片"(imgUrl)不是"封面图",提交时自动取第一张图当 faceUrl,跟问吧一样
* - 主/副标签必填
* - 完全没有推送功能,不管是不是官方账号——这是跟帖子/问吧/专栏/专访最大的区别
* - 官方账号可以选一个"所属一级部门",选填,不是必填项
* - 提交走的是单独的 addOrUpdatePractice 接口(不是 addOrUpdateArticle),这个在外层
* publishLongArticle/index.vue 里按 type 分支处理,这个组件只管收集字段
*/
import type { FormInstance, FormItemRule } from 'element-plus'
import { firstDepListOptions, BooleanFlag, SendTypeEnum } from '@/constants'
import UploadFile from '@/components/common/UploadFile/index.vue'
import SelectTags from '@/components/common/SelectTags/index.vue'
import type { ArticleItemDto } from '@/api/article/types'
import { useResetData } from '@/hooks'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const formRef = useTemplateRef<FormInstance>('formRef')
const [form, resetForm] = useResetData({
imgUrl: '',
mainTagId: '',
tagList: [] as number[],
deptId: '',
sendType: SendTypeEnum.IMMEDIATE,
sendTime: '',
regionHide: BooleanFlag.NO,
})
const filterTagsFn = (allTags: any[]) => allTags.filter((tag) => tag.id !== Number(form.value.mainTagId))
// 时间选择只允许整十分(:00、:10...:50),秒固定为 0
const disabledSecondsOnlyZero = () => Array.from({ length: 59 }, (_, i) => i + 1)
const disabledMinutesOnlyTens = () =>
Array.from({ length: 60 }, (_, i) => i).filter((m) => m % 10 !== 0)
const rules: Record<string, FormItemRule[]> = {
imgUrl: [{ required: true, message: '请上传内容图片', trigger: 'blur' }],
mainTagId: [{ required: true, message: '请选择主标签', trigger: 'blur' }],
sendType: [{ required: true, message: '请选择发布类型', trigger: 'blur' }],
sendTime: [
{
validator: (rule, value, callback) => {
if (form.value.sendType === SendTypeEnum.SCHEDULED && !value) {
callback(new Error('请选择发布时间'))
} else {
callback()
}
},
trigger: 'trigger',
},
],
regionHide: [{ required: true, message: '请选择是否隐藏IP信息', trigger: 'trigger' }],
}
defineExpose({
validate: () => formRef.value!.validate(),
getSubmitData: () => ({
imgUrl: form.value.imgUrl,
faceUrl: form.value.imgUrl ? form.value.imgUrl.split(',')[0] || '' : '',
mainTagId: form.value.mainTagId,
tagList: [form.value.mainTagId, ...form.value.tagList].map((item, index) => ({
tagId: Number(item),
sort: index,
})),
sendType: form.value.sendType,
sendTime: form.value.sendTime,
regionHide: form.value.regionHide,
...(userInfo.value.isOfficialAccount ? { deptId: form.value.deptId || '' } : {}),
}),
setEchoData: (data: ArticleItemDto) => {
form.value.imgUrl = data.imgUrl
form.value.mainTagId = data.tagIdList[0] ? String(data.tagIdList[0]) : ''
form.value.tagList = data.tagIdList.slice(1) || []
form.value.sendType = data.sendType
form.value.sendTime = data.sendTime
form.value.regionHide = data.regionHide
if (userInfo.value.isOfficialAccount) {
form.value.deptId = data.deptId === '0' ? '' : data.deptId
}
},
reset: () => {
resetForm()
},
})
</script>
import type { ArticleItemDto } from '@/api/article/types'
/**
* 每个文章类型子表单组件(PostForm/ColumnForm/InterviewForm/QuestionForm/PracticeForm)
* 都要通过 defineExpose 暴露这一套方法,外层 publishLongArticle/index.vue 只认这个接口,
* 不关心某个类型内部具体有哪些字段——加/改某个类型的专属字段,只改对应的子组件就行。
*/
export interface ArticleTypeFormExpose {
/** 校验自己负责的字段,跟 el-form.validate() 一样,校验不通过会 reject */
validate: () => Promise<unknown>
/** 提交时调用,返回自己负责的字段,外层会跟公共字段(标题/内容/发布时间/IP信息等)合并后一起提交 */
getSubmitData: () => Record<string, unknown>
/** 编辑回显时调用,传入后端返回的文章详情,自己挑需要的字段回显 */
setEchoData: (data: ArticleItemDto) => void
/** 重置自己负责的字段(取消、或者 keep-alive 场景下再次打开同一类型的发布页时调用) */
reset: () => void
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment