Files
TakeoutSaaS.AdminUI/src/views/tenant/announcements/create.vue
2026-01-29 04:21:09 +00:00

747 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="art-page-view">
<!-- 1. 顶部操作区 -->
<ElCard class="mb-4" shadow="never">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-lg font-semibold">
{{
isEditMode ? $t('tenant.announcement.editTitle') : $t('tenant.announcement.createTitle')
}}
</div>
<div class="flex items-center gap-2">
<ElTag v-if="draftStatus" type="info">{{ draftStatus }}</ElTag>
<ElButton @click="handleBack">{{ $t('tenant.announcement.action.back') }}</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit" v-ripple>
{{
isEditMode
? $t('tenant.announcement.action.update')
: $t('tenant.announcement.action.create')
}}
</ElButton>
</div>
</div>
</ElCard>
<ElAlert v-if="errorMessage" type="error" show-icon :title="errorMessage" class="mb-4" />
<ElAlert
v-else
type="info"
show-icon
:title="$t('tenant.announcement.tip.tenantScope')"
class="mb-4"
/>
<ElAlert
v-if="draftLoaded"
type="success"
show-icon
:title="$t('tenant.announcement.tip.draftLoaded')"
class="mb-4"
/>
<!-- 2. 表单区 -->
<ElCard shadow="never" v-loading="loading || loadingData">
<ElForm ref="formRef" :model="formState" :rules="rules" label-width="110px">
<!-- 2.1 基础信息 -->
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem :label="$t('tenant.announcement.field.title')" prop="title">
<ElInput
v-model="formState.title"
:placeholder="$t('tenant.announcement.placeholder.title')"
/>
</ElFormItem>
</ElCol>
<ElCol :span="6">
<ElFormItem
:label="$t('tenant.announcement.field.announcementType')"
prop="announcementType"
>
<ElSelect
v-model="formState.announcementType"
:placeholder="$t('tenant.announcement.placeholder.type')"
class="w-full"
>
<ElOption
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="6">
<ElFormItem :label="$t('tenant.announcement.field.priority')" prop="priority">
<ElInputNumber
v-model="formState.priority"
:min="1"
:max="10"
controls-position="right"
class="w-full"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem
:label="$t('tenant.announcement.field.effectiveRange')"
prop="effectiveRange"
>
<ElDatePicker
v-model="formState.effectiveRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="$t('tenant.announcement.placeholder.effectiveRange')"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem :label="$t('tenant.announcement.field.targetType')" prop="targetType">
<ElSelect
v-model="formState.targetType"
:placeholder="$t('tenant.announcement.placeholder.targetType')"
class="w-full"
>
<ElOption
v-for="item in targetTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider class="my-4" />
<!-- 2.2 公告内容 -->
<div class="mb-3 text-sm font-semibold">
{{ $t('tenant.announcement.section.content') }}
</div>
<ElFormItem :label="$t('tenant.announcement.field.content')" prop="content">
<RichTextEditor
v-model="formState.content"
:placeholder="$t('tenant.announcement.placeholder.content')"
height="320px"
/>
</ElFormItem>
<ElDivider class="my-4" />
<!-- 2.3 目标受众 -->
<div class="mb-3 text-sm font-semibold">
{{ $t('tenant.announcement.section.audience') }}
</div>
<ElRow :gutter="16">
<ElCol :span="12" v-if="formState.targetType === 'roles'">
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
<ElInput
v-model="formState.roleIds"
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12" v-if="formState.targetType === 'users'">
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
<ElInput
v-model="formState.userIds"
:placeholder="$t('tenant.announcement.placeholder.userIds')"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12" v-if="formState.targetType === 'manual'">
<ElFormItem :label="$t('tenant.announcement.field.targetParameters')">
<ElInput
v-model="formState.userIds"
:placeholder="$t('tenant.announcement.placeholder.userIds')"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="16" v-if="formState.targetType === 'rules'">
<ElCol :span="8">
<ElFormItem :label="$t('tenant.announcement.field.departmentIds')">
<ElInput
v-model="formState.departmentIds"
:placeholder="$t('tenant.announcement.placeholder.departmentIds')"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem :label="$t('tenant.announcement.field.roleIds')">
<ElInput
v-model="formState.roleIds"
:placeholder="$t('tenant.announcement.placeholder.roleIds')"
/>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem :label="$t('tenant.announcement.field.tagIds')">
<ElInput
v-model="formState.tagIds"
:placeholder="$t('tenant.announcement.placeholder.tagIds')"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import {
ElAlert,
ElButton,
ElCard,
ElCol,
ElDatePicker,
ElDivider,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElRow,
ElSelect,
ElTag,
type FormInstance,
type FormRules
} from 'element-plus'
import { useAnnouncementStore } from '@/store/modules/announcement'
import { useUserStore } from '@/store/modules/user'
import { normalizeAnnouncementStatus } from '@/utils/announcementStatus'
import type {
AnnouncementFormData,
AnnouncementTargetType,
TargetRules
} from '@/types/announcement'
import { TenantAnnouncementType } from '@/types/announcement'
import RichTextEditor from '@/components/common/RichTextEditor.vue'
import { tenantAnnouncementApi } from '@/api/announcement'
import type { TenantAnnouncementDto } from '@/types/announcement'
defineOptions({ name: 'TenantAnnouncementCreate' })
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const announcementStore = useAnnouncementStore()
const { loading, currentDraft } = storeToRefs(announcementStore)
// 1. 租户ID优先路由参数其次用户信息
const tenantId = computed(() => {
const paramId = route.params.tenantId
const queryId = route.query.tenantId
const rawId = Array.isArray(paramId)
? paramId[0]
: paramId || (Array.isArray(queryId) ? queryId[0] : queryId)
return String(rawId || userStore.info?.tenantId || '')
})
// 1.5 编辑模式检测与数据加载
const announcementId = computed(() => {
const routeId = route.params.id
return Array.isArray(routeId) ? routeId[0] : routeId
})
const isEditMode = computed(() => !!announcementId.value)
const loadingData = ref(false)
const currentRowVersion = ref<string | null>(null)
// 加载公告数据(编辑模式)
const loadAnnouncementData = async () => {
if (!isEditMode.value || !tenantId.value || !announcementId.value) return
loadingData.value = true
errorMessage.value = null
try {
const data = await tenantAnnouncementApi.detail(tenantId.value, announcementId.value)
// 检查是否只有草稿可编辑
if (normalizeAnnouncementStatus(data.status) !== 'Draft') {
errorMessage.value = t('tenant.announcement.message.onlyDraftEditable')
return
}
// 保存 RowVersion
currentRowVersion.value = data.rowVersion || null
// 映射 DTO 到表单数据
const dtoToFormData = (dto: TenantAnnouncementDto): Partial<AnnouncementFormData> => {
let targetRules: TargetRules | undefined
let targetUserIds: string[] | undefined
// 解析 targetParameters JSON 字符串
if (dto.targetParameters) {
try {
const params = JSON.parse(dto.targetParameters)
if (dto.targetType === 'rules') {
targetRules = params
} else if (dto.targetType === 'users' || dto.targetType === 'manual') {
targetUserIds = params.userIds || []
} else if (dto.targetType === 'roles') {
targetRules = { roles: params.roles || [] }
}
} catch (error) {
console.error('Failed to parse targetParameters:', error)
}
}
return {
title: dto.title,
content: dto.content,
announcementType: dto.announcementType,
priority: dto.priority,
effectiveFrom: dto.effectiveFrom,
effectiveTo: dto.effectiveTo,
targetType: dto.targetType,
targetRules,
targetUserIds
}
}
applyDraftToForm(dtoToFormData(data))
} catch (error: any) {
console.error('Failed to load announcement:', error)
errorMessage.value =
error?.response?.data?.message || t('tenant.announcement.message.loadFailed')
} finally {
loadingData.value = false
}
}
// 2. 错误提示与草稿状态
const errorMessage = ref<string | null>(null)
const draftLoaded = ref(false)
const draftStatus = computed(() => {
if (!currentDraft.value?.lastSaved) {
return t('tenant.announcement.tip.draftAutoSave')
}
return t('tenant.announcement.tip.draftSaved', {
time: formatDateTime(currentDraft.value.lastSaved)
})
})
// 3. 表单数据UI 形态)
interface AnnouncementFormState {
title: string
content: string
announcementType: TenantAnnouncementType
priority: number
effectiveRange: string[]
targetType: AnnouncementTargetType
roleIds: string
userIds: string
departmentIds: string
tagIds: string
}
const formState = reactive<AnnouncementFormState>({
title: '',
content: '',
announcementType: TenantAnnouncementType.TENANT_INTERNAL,
priority: 3,
effectiveRange: [],
targetType: 'all',
roleIds: '',
userIds: '',
departmentIds: '',
tagIds: ''
})
// 4. 草稿数据(对接 Store
const draftState = reactive<AnnouncementFormData>({
title: '',
content: '',
announcementType: TenantAnnouncementType.TENANT_INTERNAL,
priority: 3,
effectiveFrom: '',
effectiveTo: null,
targetType: 'all'
})
// 5. 选项数据
const typeOptions = computed(() => [
{ label: t('tenant.announcement.type.system'), value: TenantAnnouncementType.System },
{ label: t('tenant.announcement.type.billing'), value: TenantAnnouncementType.Billing },
{ label: t('tenant.announcement.type.operation'), value: TenantAnnouncementType.Operation },
{
label: t('tenant.announcement.type.systemPlatformUpdate'),
value: TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE
},
{
label: t('tenant.announcement.type.systemSecurityNotice'),
value: TenantAnnouncementType.SYSTEM_SECURITY_NOTICE
},
{
label: t('tenant.announcement.type.systemCompliance'),
value: TenantAnnouncementType.SYSTEM_COMPLIANCE
},
{
label: t('tenant.announcement.type.tenantInternal'),
value: TenantAnnouncementType.TENANT_INTERNAL
},
{
label: t('tenant.announcement.type.tenantFinance'),
value: TenantAnnouncementType.TENANT_FINANCE
},
{
label: t('tenant.announcement.type.tenantOperation'),
value: TenantAnnouncementType.TENANT_OPERATION
}
])
const targetTypeOptions = computed(() => [
{ label: t('tenant.announcement.targetType.all'), value: 'all' },
{ label: t('tenant.announcement.targetType.roles'), value: 'roles' },
{ label: t('tenant.announcement.targetType.users'), value: 'users' },
{ label: t('tenant.announcement.targetType.rules'), value: 'rules' },
{ label: t('tenant.announcement.targetType.manual'), value: 'manual' }
])
// 6. 表单验证
const rules: FormRules = {
title: [
{
required: true,
message: t('tenant.announcement.validation.titleRequired'),
trigger: 'blur'
},
{
min: 2,
max: 128,
message: t('tenant.announcement.validation.titleLength'),
trigger: 'blur'
}
],
content: [
{
required: true,
message: t('tenant.announcement.validation.contentRequired'),
trigger: 'blur'
}
],
announcementType: [
{
required: true,
message: t('tenant.announcement.validation.typeRequired'),
trigger: 'change'
}
],
priority: [
{
required: true,
message: t('tenant.announcement.validation.priorityRequired'),
trigger: 'change'
}
],
effectiveRange: [
{
required: true,
type: 'array',
min: 2,
message: t('tenant.announcement.validation.effectiveRangeRequired'),
trigger: 'change'
}
],
targetType: [
{
required: true,
message: t('tenant.announcement.validation.targetTypeRequired'),
trigger: 'change'
}
]
}
const formRef = ref<FormInstance>()
// 7. 时间处理
const formatDateTime = (value?: string | null) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}
const toIsoDateTime = (value: string) => {
if (!value) return ''
const normalized = value.includes('T') ? value : value.replace(' ', 'T')
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString()
}
const formatDateTimeInput = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (num: number) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// 8. 目标受众处理
const splitIds = (value: string) => {
return value
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
const buildTargetRules = () => {
if (formState.targetType === 'roles') {
return { roles: splitIds(formState.roleIds) }
}
if (formState.targetType === 'rules') {
return {
departments: splitIds(formState.departmentIds),
roles: splitIds(formState.roleIds),
tags: splitIds(formState.tagIds)
}
}
return undefined
}
const buildTargetUserIds = () => {
if (formState.targetType === 'users' || formState.targetType === 'manual') {
return splitIds(formState.userIds)
}
return undefined
}
const validateTargetInputs = () => {
if (formState.targetType === 'roles' && splitIds(formState.roleIds).length === 0) {
ElMessage.warning(t('tenant.announcement.validation.roleIdsRequired'))
return false
}
if (
(formState.targetType === 'users' || formState.targetType === 'manual') &&
splitIds(formState.userIds).length === 0
) {
ElMessage.warning(t('tenant.announcement.validation.userIdsRequired'))
return false
}
if (formState.targetType === 'rules') {
const hasRule =
splitIds(formState.departmentIds).length > 0 ||
splitIds(formState.roleIds).length > 0 ||
splitIds(formState.tagIds).length > 0
if (!hasRule) {
ElMessage.warning(t('tenant.announcement.validation.targetRuleRequired'))
return false
}
}
return true
}
// 9. 同步草稿数据
const syncDraftState = () => {
// 1. 同步基础字段
const [start, end] = formState.effectiveRange
draftState.title = formState.title
draftState.content = formState.content
draftState.announcementType = formState.announcementType
draftState.priority = formState.priority
// 2. 同步有效期
draftState.effectiveFrom = toIsoDateTime(start)
draftState.effectiveTo = end ? toIsoDateTime(end) : null
// 3. 同步受众参数
draftState.targetType = formState.targetType
draftState.targetRules = buildTargetRules()
draftState.targetUserIds = buildTargetUserIds()
}
const applyDraftToForm = (draft: Partial<AnnouncementFormData>) => {
// 1. 回填基础字段
formState.title = draft.title || ''
formState.content = draft.content || ''
formState.announcementType = draft.announcementType ?? TenantAnnouncementType.TENANT_INTERNAL
formState.priority = draft.priority ?? 3
formState.targetType = draft.targetType ?? 'all'
// 2. 回填有效期
const start = formatDateTimeInput(draft.effectiveFrom)
const end = formatDateTimeInput(draft.effectiveTo ?? null)
formState.effectiveRange = start && end ? [start, end] : []
// 3. 回填受众参数
formState.roleIds = draft.targetRules?.roles?.join(',') || ''
formState.departmentIds = draft.targetRules?.departments?.join(',') || ''
formState.tagIds = draft.targetRules?.tags?.join(',') || ''
formState.userIds = draft.targetUserIds?.join(',') || ''
}
// 10. 草稿自动保存
const stopAutoSave = ref<(() => void) | null>(null)
const initDraft = () => {
// 1. 同步草稿数据
syncDraftState()
// 2. 若无草稿则先写入本地
if (!currentDraft.value) {
announcementStore.saveDraft(draftState)
}
// 3. 启动自动保存
stopAutoSave.value = announcementStore.autoSaveDraft(draftState, 30000)
}
// 11. 提交创建/更新
const handleSubmit = async () => {
if (!formRef.value) return
if (!tenantId.value) {
ElMessage.error(t('tenant.announcement.message.tenantIdMissing'))
return
}
try {
// 1. 表单校验
await formRef.value.validate()
if (!validateTargetInputs()) return
// 2. 组装请求参数
syncDraftState()
const payload: AnnouncementFormData = {
...draftState,
title: formState.title.trim(),
content: formState.content.trim()
}
// 3. 编辑模式:包含 RowVersion
if (isEditMode.value && announcementId.value) {
if (currentRowVersion.value) {
payload.rowVersion = currentRowVersion.value
}
const result = await tenantAnnouncementApi.update(
tenantId.value,
announcementId.value,
payload
)
ElMessage.success(t('tenant.announcement.message.updateSuccess'))
// 跳转详情页
router.push({
path: `${listPath.value}/${result.id}`,
query: tenantId.value ? { tenantId: tenantId.value } : undefined
})
return
}
// 4. 创建模式
const result = await announcementStore.createAnnouncement(payload, {
scope: 'tenant',
tenantId: tenantId.value
})
if (result) {
ElMessage.success(t('tenant.announcement.message.createSuccess'))
// 5. 清理草稿
if (currentDraft.value?.draftId) {
announcementStore.clearDraft(currentDraft.value.draftId)
}
// 6. 跳转详情页
router.push({
path: `${listPath.value}/${result.id}`,
query: tenantId.value ? { tenantId: tenantId.value } : undefined
})
}
} catch (error: any) {
console.error(error)
// 处理并发冲突
if (error?.response?.status === 409) {
ElMessage.error(t('tenant.announcement.message.concurrentConflict'))
} else {
ElMessage.error(
error?.response?.data?.message || t('tenant.announcement.message.operationFailed')
)
}
}
}
// 12. 返回列表
const listPath = computed(() => route.path.replace(/\/(create|edit\/[^/]+)$/, ''))
const handleBack = () => {
router.push({
path: listPath.value,
query: tenantId.value ? { tenantId: tenantId.value } : undefined
})
}
// 13. 初始化草稿与错误提示
watch(
tenantId,
(value) => {
if (!value) {
errorMessage.value = t('tenant.announcement.message.tenantIdMissing')
} else {
errorMessage.value = null
}
},
{ immediate: true }
)
watch(
formState,
() => {
// 1. 表单变化时同步草稿数据
syncDraftState()
},
{ deep: true }
)
const draftIdFromRoute = computed(() => {
const draftId = route.query.draftId
return Array.isArray(draftId) ? draftId[0] : draftId
})
// 14. 初始化逻辑
if (isEditMode.value) {
// 编辑模式:加载公告数据
loadAnnouncementData()
} else if (draftIdFromRoute.value) {
// 创建模式:加载草稿
const draft = announcementStore.loadDraft(String(draftIdFromRoute.value))
if (draft) {
applyDraftToForm(draft)
draftLoaded.value = true
}
}
// 创建模式下初始化草稿自动保存
if (!isEditMode.value) {
initDraft()
}
onBeforeUnmount(() => {
// 1. 组件卸载时停止自动保存
stopAutoSave.value?.()
})
</script>