chore: 初始化平台管理端

This commit is contained in:
msumshk
2026-01-29 04:21:09 +00:00
commit 914dcc4166
533 changed files with 104838 additions and 0 deletions

View File

@@ -0,0 +1,746 @@
<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>