128 lines
9.0 KiB
Vue
128 lines
9.0 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { CheckCheck } 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 detailRow = ref<NotificationInboxItem | null>(null)
|
||
let detailRequest = 0
|
||
watch(detailOpen, value => { if (!value) { detailRequest++; detailRow.value = 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) {
|
||
const request = ++detailRequest
|
||
detailRow.value = row
|
||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||
try {
|
||
const result = await getMyNotification(row.id)
|
||
if (request !== detailRequest || !detailOpen.value) return
|
||
detail.value = result
|
||
if (result.status === 'UNREAD' && canRead.value) {
|
||
const read = await markMyNotificationRead(row.id)
|
||
if (request !== detailRequest || !detailOpen.value) return
|
||
detail.value = read
|
||
await load()
|
||
}
|
||
} catch (reason) { if (request === detailRequest && detailOpen.value) detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { if (request === detailRequest) 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 class="admin-density admin-workbench">
|
||
<ListPageLayout aria-label="我的消息列表">
|
||
|
||
|
||
<section class="workbench-table is-page-surface"><DataTable preference-key="notifications-platformnotificationsview" appearance="integrated" column-settings-mode="show" :results-visible="!error" 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-start><h1>我的消息</h1></template>
|
||
<template #filters><QueryToolbar v-model="filters" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
|
||
<template #notices><template v-if="error"><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
|
||
|
||
|
||
<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="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationInboxItem)">查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" class="density-action" data-tone="neutral" @click="markRead(row as NotificationInboxItem)">标为已读</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 class="admin-density admin-density-drawer admin-density-drawer--detail admin-workbench is-drawer" 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" @retry="detailRow && openDetail(detailRow)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||
<article v-else-if="detail" class="density-dialog-sections notification-detail">
|
||
<section class="density-dialog-section" data-tone="summary" aria-label="消息资料">
|
||
<h3 class="density-dialog-section__title message-heading"><span>{{ detail.title || '消息详情' }}</span><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></h3>
|
||
<p class="density-dialog-note">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p>
|
||
</section>
|
||
<section class="density-dialog-section" data-tone="source" aria-label="消息正文"><h3 class="density-dialog-section__title">消息正文</h3><div class="message-body">{{ detail.body || '暂无消息内容' }}</div></section>
|
||
</article>
|
||
</AppDrawer>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.message-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); }
|
||
.message-heading > span { min-width: 0; overflow-wrap: anywhere; }
|
||
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
</style>
|