优化
This commit is contained in:
@@ -14,9 +14,7 @@
|
||||
| 模板管理 | 新增模板 | `modal-msg-template-create`,约 760px 居中弹框 | 760px `AppDialog`;CODE、名称、分类、标题/正文、变量白名单、预览;保存为 DRAFT | 仅支持 `IN_APP`,不展示真实渠道选择 |
|
||||
| 模板管理 | 编辑模板 | 原型仅有编辑按钮,未定义容器 | 复用同尺寸模板弹框,仅 DRAFT 可编辑,CODE 不可变并携带 version | 冻结契约明确只允许编辑草稿,复用表单避免重复交互 |
|
||||
| 模板管理 | 状态流转 | 原型未定义 | DRAFT 可激活,ACTIVE 可禁用,DISABLED 无操作 | 严格执行 `DRAFT -> ACTIVE -> DISABLED`;禁用后不可恢复 |
|
||||
| 我的消息 | 列表/查询 | `page-msg-my` 页面内表格 | `/notifications/mine` 查询当前平台员工收件箱,支持状态、分类筛选;操作列固定第一列 | 使用 `platform` app,服务端从登录上下文隔离,不传企业 ID |
|
||||
| 我的消息 | 查看/标为已读 | 原型仅有查看按钮;支持单条和全部已读 | 680px 居中详情弹框;打开未读详情自动标记;支持单条和全部标为已读 | 对接 detail/read/read-all;变更后刷新顶部未读数 |
|
||||
| 我的消息 | 清空已读 | 原型列表按钮 | 不展示 | 首期没有删除或清空已读 API,不能展示可点击后报错的入口 |
|
||||
| 顶部入口 | 未读入口/三页跳转 | `topMessageMenu` 顶部下拉 | 有收件箱权限时加载真实未读数;按任务/模板权限展示管理入口,始终提供“我的消息” | 对接 `/platform/v1/notifications/unread-count`;加载失败显示异常标记并保留可访问入口 |
|
||||
| 我的消息 | 页面归属 | 原型保留 `page-msg-my` 历史页面 | Admin 不实现、不注册路由 | 产品确认“我的消息”仅属于企业客户端 |
|
||||
| 顶部入口 | 管理入口 | `topMessageMenu` 顶部下拉,左侧无消息菜单 | 按任务/模板权限展示“消息管理”“模板管理” | Admin 仅保留后台管理能力,不展示个人收件箱与未读数 |
|
||||
|
||||
接口依赖:所有响应经 `{code,data}` 解包;分页统一读取 `records/page/size/total/pages`;响应中的 `int64` ID 在前端保持字符串。写请求中的企业、用户和成员 ID 也在类型适配边界保持数字字符串,避免 JavaScript 大整数精度损失,由服务端 Long DTO 解析。
|
||||
|
||||
@@ -4,13 +4,8 @@ import {
|
||||
cancelTask,
|
||||
changeTemplateStatus,
|
||||
createNotification,
|
||||
getInboxDetail,
|
||||
getUnreadCount,
|
||||
listInbox,
|
||||
listTasks,
|
||||
listTemplates,
|
||||
markAllInboxRead,
|
||||
markInboxRead,
|
||||
retryTask,
|
||||
updateTemplate,
|
||||
} from './notificationCenter'
|
||||
@@ -21,38 +16,12 @@ vi.mock('@/shared/api/http', () => ({
|
||||
}))
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', data })
|
||||
const inbox = { id: '9007199254740993', category: 'ORDER', title: '已发货', status: 'UNREAD' as const, sentAt: '2026-08-12T10:00:00Z', readAt: null }
|
||||
const template = { id: 12, templateCode: 'ORDER_SENT', templateName: '订单已发货', category: 'ORDER', channel: 'IN_APP' as const, titleTemplate: '已发货', bodyTemplate: '正文', variableWhitelist: [], status: 'DRAFT' as const, version: 1 }
|
||||
const task = { id: 13, taskNo: 'NT-13', category: 'ORDER', title: '已发货', status: 'CREATED' as const, recipientCount: 2, scheduledAt: null, sentAt: null, duplicated: false }
|
||||
|
||||
describe('notificationCenter API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the platform inbox paths and normalizes envelope pagination and ids', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [inbox], page: '1', size: '20', total: '1', pages: '1' }))
|
||||
const result = await listInbox({ page: 1, size: 20, status: 'UNREAD' })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
|
||||
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
|
||||
expect(result.records[0]?.id).toBe(String(inbox.id))
|
||||
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ unreadCount: '7' }))
|
||||
expect(await getUnreadCount()).toBe(7)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/notifications/unread-count')
|
||||
})
|
||||
|
||||
it('gets and marks inbox messages without a request body', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
|
||||
vi.mocked(http.put)
|
||||
.mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
|
||||
.mockResolvedValueOnce(envelope({ updatedCount: '3' }))
|
||||
await getInboxDetail('10/20')
|
||||
await markInboxRead('10/20')
|
||||
expect(await markAllInboxRead()).toBe(3)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications/10%2F20')
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/10%2F20/read')
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
|
||||
})
|
||||
|
||||
it('posts the frozen create-notification DTO unchanged', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce(envelope(task))
|
||||
const payload = { tenantId: '0', templateCode: 'ORDER_SENT', recipientType: 'PLATFORM_ROLE' as const, roleCode: 'PLATFORM_ADMIN', variables: {}, scheduledAt: null, idempotencyKey: 'notification-1' }
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, InboxDetail, InboxItem, InboxStatus, NotificationTask, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, 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 RawInbox = Omit<InboxItem, 'id'> & { id: RawId }
|
||||
type RawInboxDetail = Omit<InboxDetail, 'id'> & { id: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
|
||||
|
||||
const inboxPath = '/platform/v1/notifications'
|
||||
const notificationPath = '/platform/v1/notifications'
|
||||
const templatePath = '/platform/v1/notification-templates'
|
||||
const taskPath = '/platform/v1/notification-tasks'
|
||||
const id = (value: RawId) => String(value)
|
||||
const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) })
|
||||
const inbox = (value: RawInbox): InboxItem => ({ ...value, id: id(value.id) })
|
||||
const inboxDetail = (value: RawInboxDetail): InboxDetail => ({ ...value, id: id(value.id) })
|
||||
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
|
||||
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
|
||||
|
||||
export async function listInbox(query: { page: number; size: number; status?: InboxStatus; 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 getUnreadCount() { const response = await http.get<ApiEnvelope<{ unreadCount: RawId }>, ApiEnvelope<{ unreadCount: RawId }>>(`${inboxPath}/unread-count`); return Number(unwrapApiResponse(response).unreadCount) }
|
||||
export async function getInboxDetail(messageId: string) { const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markInboxRead(messageId: string) { const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}/read`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markAllInboxRead() { const response = await http.put<ApiEnvelope<{ updatedCount: RawId }>, ApiEnvelope<{ updatedCount: RawId }>>(`${inboxPath}/read-all`); return Number(unwrapApiResponse(response).updatedCount) }
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(inboxPath, payload); return task(unwrapApiResponse(response)) }
|
||||
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) }) }
|
||||
export async function createTemplate(payload: CreateTemplateRequest) { const response = await http.post<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(templatePath, payload); return template(unwrapApiResponse(response)) }
|
||||
export async function updateTemplate(templateId: string, payload: UpdateTemplateRequest) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${templatePath}/${encodeURIComponent(templateId)}`, payload); return template(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -27,14 +27,12 @@ describe('notification center contract UI', () => {
|
||||
it.each([
|
||||
'./pages/MessageManagementView.vue',
|
||||
'./pages/MessageTemplatesView.vue',
|
||||
'./pages/MyMessagesView.vue',
|
||||
])('keeps actions as the first table column in %s', (path) => {
|
||||
const content = source(path)
|
||||
expect(content).toMatch(/const columns:[^=]+\s*=\s*\[\{key:'actions',label:'操作'/)
|
||||
})
|
||||
|
||||
it('does not expose unsupported clear-read or disabled-template restore actions', () => {
|
||||
expect(source('./pages/MyMessagesView.vue')).not.toContain('清空已读')
|
||||
const templates = source('./pages/MessageTemplatesView.vue')
|
||||
expect(templates).toContain("status!=='DISABLED'")
|
||||
expect(templates).toContain("status==='DRAFT'?'激活':'禁用'")
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/notifications/pages/MyMessagesView.vue'), 'utf8')
|
||||
|
||||
describe('MyMessagesView permissions', () => {
|
||||
it('keeps inbox viewing separate from read operations', () => {
|
||||
expect(source).toContain("access.can('platform:notification:read')")
|
||||
expect(source).toContain("access.can('platform:notification:read-all')")
|
||||
expect(source).toContain("detail.value.status==='UNREAD'&&canMarkRead.value")
|
||||
expect(source).toContain('v-if="canMarkAllRead"')
|
||||
expect(source).toContain("v-if=\"canMarkRead&&(row as InboxItem).status==='UNREAD'\"")
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'; import { CheckCheck, Eye } from '@lucide/vue'; import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.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 { getInboxDetail,listInbox,markAllInboxRead,markInboxRead } from '../api/notificationCenter'; import type { InboxDetail,InboxItem,InboxStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),canMarkRead=computed(()=>access.can('platform:notification:read')),canMarkAllRead=computed(()=>access.can('platform:notification:read-all'))
|
||||
const page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),detailOpen=ref(false),detailLoading=ref(false),detail=ref<InboxDetail|null>(null),result=ref({records:[] as InboxItem[],total:0,pages:0}),filters=ref<QueryModel>({status:'',category:''})
|
||||
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:210,fixed:'left',slotName:'actions'},{key:'title',prop:'title',label:'消息标题',minWidth:240},{key:'category',prop:'category',label:'业务分类',width:140},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:100,slotName:'status'}]
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const d=await listInbox({page:page.value,size:pageSize.value,status:(filters.value.status||undefined) as InboxStatus|undefined,category:String(filters.value.category||'').trim()||undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'我的消息加载失败')}finally{loading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function openDetail(row:InboxItem){detailOpen.value=true;detailLoading.value=true;detail.value=null;try{detail.value=await getInboxDetail(row.id);if(detail.value.status==='UNREAD'&&canMarkRead.value){detail.value=await markInboxRead(row.id);await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}}catch(e){ElMessage.error(getApiErrorMessage(e,'消息详情加载失败'))}finally{detailLoading.value=false}}
|
||||
async function markRead(row:InboxItem){actionId.value=row.id;try{await markInboxRead(row.id);ElMessage.success('已标为已读');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'标记已读失败'))}finally{actionId.value=''}}
|
||||
async function markAll(){try{const count=await markAllInboxRead();ElMessage.success(count?`已将 ${count} 条消息标为已读`:'没有需要更新的未读消息');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'全部标为已读失败'))}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="我的消息列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-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" preference-key="notifications-mine-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息"><template #toolbar-actions><el-button v-if="canMarkAllRead" @click="markAll"><CheckCheck :size="16"/>全部标为已读</el-button></template><template #pagination-left><span>共 {{result.total}} 条消息</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as InboxItem)"><Eye :size="15"/>查看</el-button><el-button v-if="canMarkRead&&(row as InboxItem).status==='UNREAD'" link type="primary" :loading="actionId===(row as InboxItem).id" @click="markRead(row as InboxItem)"><CheckCheck :size="15"/>标为已读</el-button></div></template><template #sentAt="{row}">{{formatDate((row as InboxItem).sentAt)}}</template><template #status="{row}"><el-tag :type="(row as InboxItem).status==='UNREAD'?'danger':'info'">{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="消息详情" width="min(680px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false"><article v-if="detail" class="message-detail"><header><div><h3>{{detail.title}}</h3><p>{{detail.category}} · {{formatDate(detail.sentAt)}}</p></div><el-tag :type="detail.status==='UNREAD'?'danger':'info'">{{detail.status==='UNREAD'?'未读':'已读'}}</el-tag></header><div class="message-body">{{detail.body}}</div><dl v-if="detail.sourceType||detail.sourceId"><div><dt>来源类型</dt><dd>{{detail.sourceType||'—'}}</dd></div><div><dt>来源标识</dt><dd>{{detail.sourceId||'—'}}</dd></div></dl></article></AppDialog></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}.message-detail header{display:flex;justify-content:space-between;gap:16px;padding-bottom:16px;border-bottom:1px solid var(--cc-border)}.message-detail h3{margin:0;font-size:17px}.message-detail p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.message-body{padding:20px 0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.message-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border)}.message-detail dl>div{padding:12px}.message-detail dt{color:var(--cc-muted);font-size:12px}.message-detail dd{margin:5px 0 0;overflow-wrap:anywhere}@media(max-width:480px){.message-detail header{flex-direction:column}.message-detail dl{grid-template-columns:1fr}}</style>
|
||||
@@ -1,11 +1,8 @@
|
||||
export type InboxStatus = 'UNREAD' | 'READ'
|
||||
export type TemplateStatus = 'DRAFT' | 'ACTIVE' | 'DISABLED'
|
||||
export type NotificationTaskStatus = 'CREATED' | 'SCHEDULED' | 'DISPATCHING' | 'RETRYING' | 'SENT' | 'DEAD' | 'CANCELLED'
|
||||
export type RecipientType = 'USER' | 'MEMBERSHIP' | 'TENANT_ROLE' | 'TENANT_ALL' | 'PLATFORM_ROLE'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface InboxItem { id: string; category: string; title: string; status: InboxStatus; sentAt: string; readAt: string | null }
|
||||
export interface InboxDetail extends InboxItem { body: string; sourceType: string | null; sourceId: string | null }
|
||||
export interface NotificationTemplate { id: string; templateCode: string; templateName: string; category: string; channel: 'IN_APP'; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[]; status: TemplateStatus; version: number }
|
||||
export interface NotificationTask { id: string; taskNo: string; category: string; title: string; status: NotificationTaskStatus; recipientCount: number; scheduledAt: string | null; sentAt: string | null; duplicated: boolean }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user