优化
This commit is contained in:
@@ -28,23 +28,32 @@ test('does not accept browser-selected customer or user scope in the API boundar
|
||||
|
||||
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="消息标题"')
|
||||
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, /notification-state--error/)
|
||||
assert.match(source, /暂无消息/)
|
||||
assert.doesNotMatch(source, /清空已读|删除消息/)
|
||||
assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/)
|
||||
assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/)
|
||||
assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/)
|
||||
assert.match(source, /v-if="canMarkAllRead"/)
|
||||
assert.match(source, /v-if="canMarkRead && row\.status === 'UNREAD'"/)
|
||||
assert.match(source, /canMarkRead\.value && row\.status === 'UNREAD'/)
|
||||
assert.match(source, /notificationCategoryLabel\(row\.type\)/)
|
||||
assert.match(source, /notificationSentAt\(row\.sentAt\)/)
|
||||
})
|
||||
|
||||
test('uses the shared compact list language without a repeated page hero', async () => {
|
||||
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
|
||||
assert.match(source, /<QueryToolbar[^>]*layout="grid"/)
|
||||
assert.match(source, /<DataTable[^>]*:row-actions="rowActions"/)
|
||||
assert.match(source, /#toolbar-actions/)
|
||||
assert.match(source, /empty-text="暂无消息"/)
|
||||
assert.doesNotMatch(source, /#toolbar>|page-heading|eyebrow|table-empty|共 \{\{ total \}\} 条/)
|
||||
})
|
||||
|
||||
test('uses friendly notification values while keeping unknown server codes visible', async () => {
|
||||
const source = await readFile(new URL('presentation.ts', moduleRoot), 'utf8')
|
||||
assert.match(source, /ORDER: '订单消息'/)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CheckCheck, RefreshCcw } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useNotificationsStore } from '../public'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
|
||||
import type { NotificationStatus, NotificationSummary } from '../contracts'
|
||||
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
|
||||
import { useNotificationsStore } from '../public'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -15,44 +18,121 @@ const notifications = useNotificationsStore()
|
||||
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
|
||||
const canMarkAllRead = computed(() => auth.hasPermission('merchant:notification:read-all'))
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
const filters = reactive<{ status?: NotificationStatus; type?: NotificationType }>({})
|
||||
const records = ref<NotificationSummary[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ status: '', type: '' })
|
||||
const queryModel = computed<QueryModel>({ get: () => ({ ...filters }), set: (value) => Object.assign(filters, value) })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'status', label: '消息状态', type: 'select', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
|
||||
{ key: 'type', label: '消息分类', type: 'input', placeholder: '请输入消息分类' },
|
||||
]
|
||||
const tableColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 128, fixed: 'left' },
|
||||
{ prop: 'title', label: '消息标题', minWidth: 210 },
|
||||
{ prop: 'summary', label: '内容摘要', minWidth: 320 },
|
||||
{ key: 'type', label: '分类', slotName: 'type', width: 130 },
|
||||
{ key: 'sentAt', label: '发送时间', slotName: 'sentAt', width: 170 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 90, align: 'center' },
|
||||
]
|
||||
|
||||
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' }
|
||||
async function load(nextPage = page.value.page) {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { page.value = await getMyNotifications({ ...filters, page: nextPage, size: page.value.size }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: nextPage, size: 20, pages: 0 } } finally { loading.value = false }
|
||||
function messageOf(error: unknown) {
|
||||
return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试'
|
||||
}
|
||||
async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return; try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markAllRead() { if (!canMarkAllRead.value) return; try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await getMyNotifications({
|
||||
status: filters.status ? filters.status as NotificationStatus : undefined,
|
||||
type: filters.type.trim() || undefined,
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
records.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
errorMessage.value = messageOf(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (currentPage.value === 1) void load()
|
||||
else currentPage.value = 1
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, { status: '', type: '' })
|
||||
search()
|
||||
}
|
||||
|
||||
async function markRead(row: NotificationSummary) {
|
||||
if (!canMarkRead.value) return
|
||||
try {
|
||||
await markMyNotificationRead(row.id)
|
||||
await Promise.all([load(), notifications.refreshUnreadCount()])
|
||||
} catch (error) {
|
||||
errorMessage.value = messageOf(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (!canMarkAllRead.value || markingAll.value) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllMyNotificationsRead()
|
||||
await Promise.all([load(), notifications.refreshUnreadCount()])
|
||||
} catch (error) {
|
||||
errorMessage.value = messageOf(error)
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: NotificationSummary) {
|
||||
void router.push({ name: 'notification-detail', params: { notificationId: row.id } })
|
||||
}
|
||||
|
||||
function rowActions(candidate: object): TableAction[] {
|
||||
const row = candidate as NotificationSummary
|
||||
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(row) }]
|
||||
if (canMarkRead.value && row.status === 'UNREAD') actions.push({ key: 'read', label: '标为已读', run: () => markRead(row) })
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) })
|
||||
watch([currentPage, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextSize !== previousSize && nextPage !== 1) { currentPage.value = 1; return }
|
||||
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(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button v-if="canMarkAllRead" 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(page.page)"><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="canMarkRead && 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 label="分类" width="140"><template #default="{ row }">{{ notificationCategoryLabel(row.type) }}</template></el-table-column><el-table-column label="发送时间" width="180"><template #default="{ row }">{{ notificationSentAt(row.sentAt) }}</template></el-table-column><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>
|
||||
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="notification-page client-ui" aria-label="我的消息">
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="消息筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
|
||||
<div class="notification-table-card">
|
||||
<DataTable v-model:page="currentPage" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="id" empty-text="暂无消息">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="canMarkAllRead" :loading="markingAll" :disabled="loading || total === 0" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button>
|
||||
</template>
|
||||
<template v-if="errorMessage" #error><div class="notification-state notification-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div></template>
|
||||
<template #type="{ row }">{{ notificationCategoryLabel(row.type) }}</template>
|
||||
<template #sentAt="{ row }">{{ notificationSentAt(row.sentAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'" effect="light">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</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}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);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}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
|
||||
/* Keep the notification filters compact and aligned with the other client lists. */
|
||||
.filter-bar { align-items: flex-end; }
|
||||
.filter-bar > .el-select { width: 190px; }
|
||||
.filter-bar > .el-input { width: 260px; }
|
||||
.filter-bar > .el-button { flex: 0 0 auto; min-height: 32px; }
|
||||
.filter-bar > .el-button + .el-button { margin-left: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
||||
.filter-bar > .el-select, .filter-bar > .el-input { width: 100%; }
|
||||
.filter-bar > .el-button { width: 100%; }
|
||||
}
|
||||
.notification-page{display:grid;min-width:0;gap:12px}.notification-table-card{min-width:0;overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.notification-state{display:flex;min-height:160px;align-items:center;justify-content:center;gap:10px;flex-direction:column;color:var(--client-muted)}.notification-state p{margin:0}.notification-state--error{margin:16px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user