优化
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { apiRequest, ApiError } from '@/api/http'
|
||||
import type { NotificationDetail, NotificationPage, NotificationQuery, NotificationStatus, NotificationSummary } from '../contracts'
|
||||
|
||||
/**
|
||||
* Message-center endpoints are intentionally kept behind this adapter until
|
||||
* the server contract is frozen. No caller may provide a customer/user scope.
|
||||
*/
|
||||
interface NotificationWire {
|
||||
id: string
|
||||
title: string
|
||||
summary?: string | null
|
||||
content?: string | null
|
||||
type: string
|
||||
sentAt?: string | null
|
||||
createdAt?: string | null
|
||||
status: NotificationStatus
|
||||
}
|
||||
|
||||
interface NotificationPageWire {
|
||||
records?: NotificationWire[]
|
||||
items?: NotificationWire[]
|
||||
total?: number
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
function mapSummary(item: NotificationWire): NotificationSummary {
|
||||
return { id: String(item.id), title: item.title, summary: item.summary ?? '', type: item.type, sentAt: item.sentAt ?? item.createdAt ?? '', status: item.status }
|
||||
}
|
||||
|
||||
export async function getMyNotifications(query: NotificationQuery = {}): Promise<NotificationPage> {
|
||||
const response = await apiRequest<NotificationPageWire>({ method: 'GET', url: '/merchant/v1/notifications', params: query })
|
||||
const records = (response.records ?? response.items ?? []).map(mapSummary)
|
||||
return { records, total: response.total ?? records.length, page: response.page ?? query.page ?? 1, size: response.size ?? query.size ?? 20 }
|
||||
}
|
||||
|
||||
export async function getMyNotification(id: string): Promise<NotificationDetail> {
|
||||
const response = await apiRequest<NotificationWire>({ method: 'GET', url: `/merchant/v1/notifications/${encodeURIComponent(id)}` })
|
||||
return { ...mapSummary(response), content: response.content ?? response.summary ?? '' }
|
||||
}
|
||||
|
||||
export async function markMyNotificationRead(id: string): Promise<void> {
|
||||
await apiRequest<void>({ method: 'POST', url: `/merchant/v1/notifications/${encodeURIComponent(id)}/read` })
|
||||
}
|
||||
|
||||
export async function markAllMyNotificationsRead(): Promise<void> {
|
||||
await apiRequest<void>({ method: 'POST', url: '/merchant/v1/notifications/read-all' })
|
||||
}
|
||||
|
||||
export async function getMyUnreadNotificationCount(): Promise<number> {
|
||||
throw new ApiError('未读数量接口尚未接入,请稍后重试', 'NOTIFICATIONS_UNREAD_COUNT_PENDING')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type NotificationStatus = 'UNREAD' | 'READ'
|
||||
export type NotificationType = 'SYSTEM' | 'BUSINESS' | 'TASK' | string
|
||||
|
||||
export interface NotificationSummary {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
type: NotificationType
|
||||
sentAt: string
|
||||
status: NotificationStatus
|
||||
}
|
||||
|
||||
export interface NotificationDetail extends NotificationSummary {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface NotificationQuery {
|
||||
status?: NotificationStatus
|
||||
type?: NotificationType
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface NotificationPage {
|
||||
records: NotificationSummary[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const moduleRoot = new URL('./', import.meta.url)
|
||||
|
||||
test('keeps message-center routes unreleased until the server contract is frozen', async () => {
|
||||
const source = await readFile(new URL('routes.ts', moduleRoot), 'utf8')
|
||||
assert.match(source, /name: 'notifications-list'[\s\S]*release: 'prototype'/)
|
||||
assert.match(source, /name: 'notification-detail'[\s\S]*release: 'prototype'/)
|
||||
})
|
||||
|
||||
test('does not accept browser-selected customer or user scope in the API boundary', async () => {
|
||||
const source = await readFile(new URL('api/notifications.ts', moduleRoot), 'utf8')
|
||||
assert.doesNotMatch(source, /tenantId|customerId|merchantId|userId/)
|
||||
assert.match(source, /merchant\/v1\/notifications/)
|
||||
assert.match(source, /read-all/)
|
||||
})
|
||||
|
||||
test('keeps customer message actions first and covers the requested local states', async () => {
|
||||
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
|
||||
const operationColumn = source.indexOf('label="操作"')
|
||||
const titleColumn = source.indexOf('label="消息标题"')
|
||||
assert.ok(operationColumn >= 0 && operationColumn < titleColumn)
|
||||
assert.match(source, /全部标为已读/)
|
||||
assert.match(source, /标为已读/)
|
||||
assert.match(source, /state-panel--error/)
|
||||
assert.match(source, /暂无消息/)
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useNotificationsStore } from './store/notifications'
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Bell } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const notificationsModule = {
|
||||
key: 'notifications',
|
||||
navigationGroup: { title: '消息中心', icon: Bell },
|
||||
routes: [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications-list',
|
||||
component: () => import('./views/NotificationsView.vue'),
|
||||
release: 'prototype',
|
||||
meta: { title: '我的消息', description: '查看本企业成员可见的站内消息', permission: 'notifications:view' },
|
||||
navigation: { key: 'notifications-list', title: '我的消息', icon: Bell },
|
||||
},
|
||||
{
|
||||
path: 'notifications/:notificationId',
|
||||
name: 'notification-detail',
|
||||
component: () => import('./views/NotificationDetailView.vue'),
|
||||
release: 'prototype',
|
||||
meta: { title: '消息详情', description: '查看消息正文', permission: 'notifications:view', keepAlive: false },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getMyUnreadNotificationCount } from '../api/notifications'
|
||||
|
||||
export const useNotificationsStore = defineStore('client-notifications', () => {
|
||||
const unreadCount = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const hasUnread = computed(() => (unreadCount.value ?? 0) > 0)
|
||||
|
||||
async function refreshUnreadCount() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
unreadCount.value = await getMyUnreadNotificationCount()
|
||||
} catch (error) {
|
||||
unreadCount.value = null
|
||||
errorMessage.value = error instanceof Error ? error.message : '未读数量加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { unreadCount, hasUnread, loading, errorMessage, refreshUnreadCount }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { getMyNotification, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationDetail } from '../contracts'
|
||||
const route = useRoute(); const router = useRouter(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref('')
|
||||
async function load(){loading.value=true;errorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));if(detail.value.status==='UNREAD')await markMyNotificationRead(detail.value.id)}catch(error){errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
|
||||
onMounted(()=>{void load()})
|
||||
</script>
|
||||
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
|
||||
<style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationPage, NotificationStatus, NotificationSummary } from '../contracts'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20 })
|
||||
const filters = reactive<{ status?: NotificationStatus }>({})
|
||||
|
||||
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' }
|
||||
async function load() {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { page.value = await getMyNotifications({ ...filters, page: 1, size: 20 }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: 1, size: 20 } } finally { loading.value = false }
|
||||
}
|
||||
async function markRead(row: NotificationSummary) { try { await markMyNotificationRead(row.id); await load() } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markAllRead() { try { await markAllMyNotificationsRead(); await load() } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-page">
|
||||
<header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header>
|
||||
<section class="content-card">
|
||||
<form class="filter-bar" @submit.prevent="load"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; load()">重置</el-button><el-button class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
|
||||
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div>
|
||||
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="类型" width="110" /><el-table-column prop="sentAt" label="发送时间" width="170" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>接口接入后,这里会显示本企业成员可见的消息。</span></div></template></el-table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.page-heading h1{font-size:21px}}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import { orderManagementModule } from './order-management/routes.ts'
|
||||
import { overseasWarehouseModule } from './overseas-warehouse/routes.ts'
|
||||
import { systemModule } from './system/routes.ts'
|
||||
import { tenantManagementModule } from './tenant-management/routes.ts'
|
||||
import { notificationsModule } from './notifications/routes.ts'
|
||||
import { getActiveWorkspaceRoutes, type ClientModuleManifest } from './types.ts'
|
||||
|
||||
export const clientModuleRegistry = [
|
||||
@@ -18,6 +19,7 @@ export const clientModuleRegistry = [
|
||||
financeModule,
|
||||
tenantManagementModule,
|
||||
accountModule,
|
||||
notificationsModule,
|
||||
systemModule,
|
||||
] satisfies readonly ClientModuleManifest[]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user