1
This commit is contained in:
@@ -4,9 +4,14 @@ import {
|
||||
cancelTask,
|
||||
changeTemplateStatus,
|
||||
createNotification,
|
||||
getMyNotification,
|
||||
getMyUnreadNotificationCount,
|
||||
getTask,
|
||||
listMyNotifications,
|
||||
listTasks,
|
||||
listTemplates,
|
||||
markAllMyNotificationsRead,
|
||||
markMyNotificationRead,
|
||||
retryTask,
|
||||
updateTemplate,
|
||||
} from './notificationCenter'
|
||||
@@ -62,4 +67,24 @@ describe('notificationCenter API', () => {
|
||||
expect(result.recipients[0]?.id).toBe('21')
|
||||
expect(result.lastError).toBe('gateway timeout')
|
||||
})
|
||||
it('uses platform inbox paths and normalizes inbox ids', async () => {
|
||||
const inbox = { id: 14, category: 'ORDER', title: 'order notice', summary: 'summary', status: 'UNREAD' as const, sentAt: '2026-08-27T10:00:00', readAt: null }
|
||||
const detail = { ...inbox, body: 'body', sourceType: null, sourceId: null }
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce(envelope({ records: [inbox], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
.mockResolvedValueOnce(envelope(detail))
|
||||
.mockResolvedValueOnce(envelope({ unreadCount: 1 }))
|
||||
vi.mocked(http.put)
|
||||
.mockResolvedValueOnce(envelope(detail))
|
||||
.mockResolvedValueOnce(envelope({ updatedCount: 1 }))
|
||||
const result = await listMyNotifications({ page: 1, size: 20, status: 'UNREAD' })
|
||||
await getMyNotification('14')
|
||||
await markMyNotificationRead('14')
|
||||
await markAllMyNotificationsRead()
|
||||
expect(await getMyUnreadNotificationCount()).toBe(1)
|
||||
expect(result.records[0]?.id).toBe('14')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/14/read')
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
|
||||
type RawId = string | number
|
||||
type RawPage<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
|
||||
type RawTaskDetail = Omit<NotificationTaskDetail, 'id' | 'recipients'> & { id: RawId; recipients: Array<Omit<NotificationTaskDetail['recipients'][number], 'id'> & { id: RawId }> }
|
||||
type RawInbox = Omit<NotificationInboxItem, 'id'> & { id: RawId }
|
||||
type RawInboxDetail = Omit<NotificationInboxDetail, 'id'> & { id: RawId }
|
||||
|
||||
const notificationPath = '/platform/v1/notifications'
|
||||
const templatePath = '/platform/v1/notification-templates'
|
||||
@@ -15,6 +17,8 @@ const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(
|
||||
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
|
||||
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
|
||||
const taskDetail = (value: RawTaskDetail): NotificationTaskDetail => ({ ...value, id: id(value.id), recipients: value.recipients.map((recipient) => ({ ...recipient, id: id(recipient.id) })) })
|
||||
const inbox = (value: RawInbox): NotificationInboxItem => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null })
|
||||
const inboxDetail = (value: RawInboxDetail): NotificationInboxDetail => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null, body: value.body ?? '', sourceType: value.sourceType ?? null, sourceId: value.sourceId ?? null })
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(notificationPath, payload); return task(unwrapApiResponse(response)) }
|
||||
export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
|
||||
@@ -25,3 +29,26 @@ export async function listTasks(query: { page: number; size: number; taskNo?: st
|
||||
export async function getTask(taskId: string) { const response = await http.get<ApiEnvelope<RawTaskDetail>, ApiEnvelope<RawTaskDetail>>(`${taskPath}/${encodeURIComponent(taskId)}`); return taskDetail(unwrapApiResponse(response)) }
|
||||
export async function cancelTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
|
||||
export async function retryTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
|
||||
|
||||
const inboxPath = '/platform/v1/notifications'
|
||||
export async function listMyNotifications(query: { page: number; size: number; status?: NotificationInboxStatus; category?: string }) {
|
||||
const response = await http.get<ApiEnvelope<RawPage<RawInbox>>, ApiEnvelope<RawPage<RawInbox>>>(inboxPath, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
return page({ ...data, records: data.records.map(inbox) })
|
||||
}
|
||||
export async function getMyNotification(notificationId: string) {
|
||||
const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}`)
|
||||
return inboxDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function markMyNotificationRead(notificationId: string) {
|
||||
const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}/read`)
|
||||
return inboxDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function markAllMyNotificationsRead() {
|
||||
const response = await http.put<ApiEnvelope<{ updatedCount: number }>, ApiEnvelope<{ updatedCount: number }>>(`${inboxPath}/read-all`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function getMyUnreadNotificationCount() {
|
||||
const response = await http.get<ApiEnvelope<{ unreadCount: number }>, ApiEnvelope<{ unreadCount: number }>>(`${inboxPath}/unread-count`)
|
||||
return unwrapApiResponse(response).unreadCount
|
||||
}
|
||||
|
||||
@@ -18,17 +18,28 @@ const recipientIdsText = ref('')
|
||||
const variables = reactive<Record<string, string>>({})
|
||||
const form = reactive({ tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE' as RecipientType, roleCode: '', scheduledAt: '' })
|
||||
const selectedTemplate = computed(() => props.templates.find((item) => item.templateCode === form.templateCode) ?? null)
|
||||
const scheduled = computed({ get: () => Boolean(form.scheduledAt), set: (value) => { if (!value) form.scheduledAt = '' } })
|
||||
function toDateTimeValue(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
const scheduled = computed({
|
||||
get: () => Boolean(form.scheduledAt),
|
||||
set: (value: boolean) => {
|
||||
if (value && !form.scheduledAt) form.scheduledAt = toDateTimeValue(new Date(Date.now() + 5 * 60 * 1000))
|
||||
if (!value) form.scheduledAt = ''
|
||||
},
|
||||
})
|
||||
const needsIds = computed(() => ['USER', 'MEMBERSHIP'].includes(form.recipientType))
|
||||
const needsRole = computed(() => ['TENANT_ROLE', 'PLATFORM_ROLE'].includes(form.recipientType))
|
||||
const allowedRecipients = computed(() => scope.value === 'platform'
|
||||
? [{ label: '指定平台用户', value: 'USER' }, { label: '平台角色', value: 'PLATFORM_ROLE' }]
|
||||
: [{ label: '指定企业成员', value: 'MEMBERSHIP' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
|
||||
: [{ label: '指定企业成员', value: 'USER' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
|
||||
|
||||
watch(scope, (value) => { form.tenantId = value === 'platform' ? '0' : ''; form.recipientType = value === 'platform' ? 'PLATFORM_ROLE' : 'TENANT_ALL'; form.roleCode = ''; recipientIdsText.value = '' })
|
||||
watch(selectedTemplate, (value) => { for (const key of Object.keys(variables)) delete variables[key]; for (const key of value?.variableWhitelist ?? []) variables[key] = '' })
|
||||
|
||||
function reset() { scope.value = 'platform'; Object.assign(form, { tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE', roleCode: '', scheduledAt: '' }); recipientIdsText.value = ''; error.value = null; for (const key of Object.keys(variables)) delete variables[key] }
|
||||
const MAX_LONG_ID = 9_223_372_036_854_775_807n
|
||||
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
|
||||
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
async function submit() {
|
||||
@@ -36,7 +47,7 @@ async function submit() {
|
||||
const recipientIds = parseIds()
|
||||
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
|
||||
if (scope.value === 'customer' && !form.tenantId) return ElMessage.warning('请选择目标企业')
|
||||
if (needsIds.value && (!recipientIds.length || recipientIds.some((value) => !/^\d+$/.test(value)))) return ElMessage.warning('请输入有效的数字用户或成员 ID')
|
||||
if (needsIds.value && (!recipientIds.length || recipientIds.length > 1000 || recipientIds.some((value) => !/^\d+$/.test(value) || BigInt(value) > MAX_LONG_ID))) return ElMessage.warning('请输入有效的数字用户 ID,单次最多 1000 个且不能超过 19 位')
|
||||
if (needsRole.value && !form.roleCode.trim()) return ElMessage.warning('请输入角色编码')
|
||||
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
|
||||
submitting.value = true
|
||||
@@ -56,7 +67,7 @@ async function submit() {
|
||||
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
|
||||
<el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="needsRole" label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></div>
|
||||
<el-form-item v-if="needsIds" label="接收人 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个数字 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<el-form-item v-if="needsIds" label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="variables"><strong>模板变量</strong><el-form-item v-for="key in selectedTemplate.variableWhitelist" :key="key" :label="key" required><el-input v-model="variables[key]" maxlength="2000" /></el-form-item></section>
|
||||
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -61,6 +61,13 @@ describe('notification center contract UI', () => {
|
||||
expect(wrapper.text()).not.toContain('存为草稿')
|
||||
})
|
||||
|
||||
it('keeps the scheduled-send switch actionable and clears its date when disabled', () => {
|
||||
const composer = source('./components/MessageComposerDialog.vue')
|
||||
expect(composer).toContain('if (value && !form.scheduledAt)')
|
||||
expect(composer).toContain('if (!value) form.scheduledAt = \'\'')
|
||||
expect(composer).toContain('Date.now() + 5 * 60 * 1000')
|
||||
})
|
||||
|
||||
it('uses the prototype editor width and exposes the real save command', () => {
|
||||
const wrapper = mount(TemplateEditorDialog, {
|
||||
props: { modelValue: true },
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Bell, CheckCheck, Eye, RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getMyNotification, getMyUnreadNotificationCount, listMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notificationCenter'
|
||||
import { notificationCategoryLabel } from '../presentation'
|
||||
import type { NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, PageResult } from '../types'
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const result = ref<PageResult<NotificationInboxItem>>({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
const filters = ref<QueryModel>({ status: '', category: '' })
|
||||
const unreadCount = ref<number | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<NotificationInboxDetail | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const canRead = computed(() => access.can('platform:notification:read'))
|
||||
const canReadAll = computed(() => access.can('platform:notification:read-all'))
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '消息状态', placeholder: '全部状态', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
|
||||
{ key: 'category', type: 'input', label: '消息分类', placeholder: '请输入消息分类' },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'title', prop: 'title', label: '消息标题', minWidth: 220 },
|
||||
{ key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 300, showOverflowTooltip: true },
|
||||
{ key: 'category', label: '分类', width: 130, slotName: 'category' },
|
||||
{ key: 'sentAt', label: '发送时间', width: 180, slotName: 'sentAt' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
]
|
||||
|
||||
function statusLabel(status: NotificationInboxStatus) { return status === 'UNREAD' ? '未读' : '已读' }
|
||||
function formatDate(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function search() { page.value === 1 ? void load() : page.value = 1 }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
result.value = await listMyNotifications({ page: page.value, size: pageSize.value, status: filters.value.status as NotificationInboxStatus || undefined, category: String(filters.value.category || '').trim() || undefined })
|
||||
unreadCount.value = await getMyUnreadNotificationCount()
|
||||
} catch (reason) {
|
||||
error.value = getApiErrorDetails(reason, '我的消息加载失败')
|
||||
result.value = { records: [], page: page.value, size: pageSize.value, total: 0, pages: 0 }
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function openDetail(row: NotificationInboxItem) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
try {
|
||||
detail.value = await getMyNotification(row.id)
|
||||
if (detail.value.status === 'UNREAD' && canRead.value) {
|
||||
detail.value = await markMyNotificationRead(row.id)
|
||||
await load()
|
||||
}
|
||||
} catch (reason) { detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { detailLoading.value = false }
|
||||
}
|
||||
async function markRead(row: NotificationInboxItem) {
|
||||
if (!canRead.value || row.status !== 'UNREAD') return
|
||||
try { await markMyNotificationRead(row.id); ElMessage.success('消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
|
||||
}
|
||||
async function markAllRead() {
|
||||
try { await markAllMyNotificationsRead(); ElMessage.success('全部消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
|
||||
}
|
||||
watch([page, pageSize], () => void load())
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="我的消息列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
|
||||
<template #toolbar-actions><el-button v-if="canReadAll && unreadCount" type="primary" plain :disabled="loading" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button></template>
|
||||
<template #pagination-left><span>共 {{ result.total }} 条<span v-if="unreadCount">,未读 {{ unreadCount }} 条</span></span></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationInboxItem)"><Eye :size="15" />查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationInboxItem)"><CheckCheck :size="15" />标为已读</el-button></div></template>
|
||||
<template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template>
|
||||
<template #sentAt="{ row }">{{ formatDate((row as NotificationInboxItem).sentAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="(row as NotificationInboxItem).status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel((row as NotificationInboxItem).status) }}</el-tag></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
|
||||
<LoadErrorState v-if="detailError" title="消息详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<article v-else-if="detail" class="notification-detail"><header><h2>{{ detail.title }}</h2><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></header><p class="meta">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p><div class="body">{{ detail.body || '暂无消息内容' }}</div></article>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
|
||||
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.notification-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.notification-detail h2 { margin: 0; color: var(--text-primary, #1f2937); font-size: 20px; line-height: 1.4; }
|
||||
.notification-detail .meta { color: var(--text-secondary, #64748b); font-size: 12px; }
|
||||
.notification-detail .body { padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); white-space: pre-wrap; line-height: 1.8; }
|
||||
</style>
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const notificationRoutes = [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notification-inbox',
|
||||
component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'),
|
||||
meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' },
|
||||
},
|
||||
{
|
||||
path: 'notifications/messages',
|
||||
name: 'notification-messages',
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface NotificationTask { id: string; taskNo: string; category: string
|
||||
export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null }
|
||||
export interface NotificationTaskDetail extends Omit<NotificationTask, 'duplicated'> { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] }
|
||||
|
||||
export type NotificationInboxStatus = 'UNREAD' | 'READ'
|
||||
export interface NotificationInboxItem { id: string; category: string; title: string; summary: string; status: NotificationInboxStatus; sentAt: string; readAt: string | null }
|
||||
export interface NotificationInboxDetail extends NotificationInboxItem { body: string; sourceType: string | null; sourceId: string | null }
|
||||
|
||||
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record<string, string>; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
|
||||
export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
|
||||
export interface UpdateTemplateRequest extends Omit<CreateTemplateRequest, 'templateCode'> { version: number }
|
||||
|
||||
Reference in New Issue
Block a user