GuDingZiChan/frontend/src/views/notifications/NotificationCenter.vue

608 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header">
<div>
<p class="page-kicker">System Records</p>
<h2 class="page-title">系统记录</h2>
</div>
<div class="flex gap-3">
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">记录总数</p>
<p class="page-meta__value">{{ total }}</p>
<p class="page-meta__subvalue">系统留痕</p>
</div>
<div class="page-meta" style="min-width: 120px;">
<p class="page-meta__label">待处理</p>
<p class="page-meta__value" :class="notificationStore.unreadCount > 0 ? 'text-status-warning' : ''">
{{ notificationStore.unreadCount }}
</p>
<p class="page-meta__subvalue">尚未查看记录</p>
</div>
</div>
</section>
<!-- 筛选 -->
<section class="card">
<div class="filter-bar">
<div class="filter-bar__inputs">
<div class="field-stack">
<label class="field-label">记录范围</label>
<div class="segmented-toggle">
<button
type="button"
class="segmented-toggle__item"
:class="{ 'is-active': statusFilter === 'all' }"
@click="switchFilter('all')"
>
全部记录
</button>
<button
type="button"
class="segmented-toggle__item"
:class="{ 'is-active': statusFilter === 'unread' }"
@click="switchFilter('unread')"
>
尚未查看
</button>
</div>
</div>
</div>
<div class="filter-bar__actions">
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchNotifications">
{{ loading ? '刷新中...' : '刷新' }}
</button>
<button
class="btn-secondary text-xs sm:text-sm"
:disabled="markAllLoading || notificationStore.unreadCount === 0"
@click="handleMarkAllRead"
>
{{ markAllLoading ? '处理中...' : '全部标为已查看' }}
</button>
</div>
</div>
</section>
<!-- 消息列表 -->
<section class="space-y-4">
<article
v-for="item in notifications"
:key="item.id"
class="card"
:class="isNotificationAttentionRequired(item) ? 'border-status-warning/40' : 'border-border'"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="badge" :class="getNotificationRecordBadge(item).badgeClass">
{{ getNotificationRecordBadge(item).label }}
</span>
<span class="text-xs text-text-tertiary">{{ formatDateTime(item.notification_created_at) }}</span>
<span v-if="!item.is_read" class="text-xs text-text-tertiary">尚未查看</span>
</div>
<h3 class="mt-3 text-lg font-semibold text-text-primary">{{ item.title }}</h3>
<p v-if="item.content" class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ item.content }}
</p>
<div
v-if="summaryPairs(item).length > 0"
class="mt-4 flex flex-wrap gap-2"
>
<span
v-for="pair in summaryPairs(item)"
:key="pair.label"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
</div>
<div class="hidden shrink-0 flex-wrap gap-3 md:flex lg:w-[220px] lg:flex-col">
<button
v-if="!item.is_read"
class="btn-secondary w-full text-sm"
@click="handleMarkRead(item)"
>
标为已查看
</button>
<button
v-if="item.action_url || item.related_sync_job_id"
class="btn-primary w-full text-sm"
@click="openAction(item)"
>
{{ getNotificationActionLabel(item) }}
</button>
</div>
<div class="flex gap-2 md:hidden">
<button
v-if="item.action_url || item.related_sync_job_id"
class="btn-primary flex-1"
@click="openAction(item)"
>
{{ getNotificationActionLabel(item) }}
</button>
<button
v-if="!item.is_read && !(item.action_url || item.related_sync_job_id)"
class="btn-secondary flex-1"
@click="handleMarkRead(item)"
>
标为已查看
</button>
<button
v-if="!item.is_read && (item.action_url || item.related_sync_job_id)"
class="btn-secondary"
@click="mobileActionNotification = item"
>
更多操作
</button>
</div>
</div>
</article>
<div v-if="!loading && notifications.length === 0" class="empty-state">
当前没有{{ statusFilter === 'unread' ? '尚未查看的' : '' }}系统记录。
</div>
</section>
<!-- 分页 -->
<section v-if="total > 0" class="card">
<div class="pagination-bar">
<p class="text-sm text-text-tertiary">
第 {{ currentPage }} / {{ totalPages }} 页,共 {{ total }} 条系统记录
</p>
<div class="flex gap-2">
<button class="btn-secondary text-sm" :disabled="currentPage === 1 || loading" @click="handlePrevPage">
上一页
</button>
<button class="btn-secondary text-sm" :disabled="currentPage >= totalPages || loading" @click="handleNextPage">
下一页
</button>
</div>
</div>
</section>
<MobileActionSheet
:open="Boolean(mobileActionNotification)"
title="记录操作"
:description="mobileActionNotification?.title || ''"
@close="mobileActionNotification = null"
>
<button type="button" class="btn-secondary w-full" @click="handleMobileMarkRead">标为已查看</button>
</MobileActionSheet>
<div v-if="syncDetailOpen" class="sheet-backdrop" @click.self="closeSyncDetail">
<div class="sheet-panel max-w-5xl">
<div class="form-panel__header">
<div>
<h3 class="form-panel__title">同步详情</h3>
<p class="form-panel__desc">
查看本次花名册同步的变更和资产处理结果。
</p>
</div>
<button type="button" class="btn-secondary text-sm" @click="closeSyncDetail">
关闭
</button>
</div>
<div v-if="syncDetailLoading" class="panel mt-4 text-sm text-text-tertiary">
正在读取同步详情...
</div>
<div v-else-if="selectedSyncJob" class="mt-4 space-y-4">
<div class="grid gap-3 md:grid-cols-4">
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行状态</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ getJobStatusLabel(selectedSyncJob.status) }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行方式</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ getTriggerModeLabel(selectedSyncJob.trigger_mode) }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行人</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ selectedSyncJob.operator || 'system-sync' }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">变化数量</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ selectedSyncJob.changes_count }}
</p>
</div>
</div>
<div class="panel">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">同步摘要</p>
<span class="text-xs text-text-tertiary">{{ formatDateTime(selectedSyncJob.created_at) }}</span>
</div>
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ selectedSyncJob.summary || '本次同步没有额外摘要。' }}
</p>
<p v-if="selectedSyncJob.error_message" class="mt-3 text-sm text-status-error">
错误信息:{{ selectedSyncJob.error_message }}
</p>
</div>
<div v-if="summaryPairs(selectedSyncJob).length > 0" class="flex flex-wrap gap-2">
<span
v-for="pair in summaryPairs(selectedSyncJob)"
:key="`detail-${pair.label}`"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
<div class="panel">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">完整变更</p>
<span class="badge badge-neutral">{{ selectedSyncJob.changes.length }} 条</span>
</div>
<div v-if="selectedSyncJob.changes.length > 0" class="mt-4 space-y-3">
<div
v-for="change in selectedSyncJob.changes"
:key="change.id"
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text-primary">{{ getChangeDisplayTitle(change) }}</p>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ change.detail || '无详细说明' }}
</p>
<div
v-if="getChangeAssets(change).length"
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
>
<p class="text-xs font-medium text-text-tertiary">
{{ change.after_snapshot?.assetOutcome ? '涉及资产' : '同步时记录的资产' }}
</p>
<ul class="mt-3 flex flex-wrap gap-2">
<li
v-for="(asset, index) in getChangeAssets(change)"
:key="index"
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-xs [overflow-wrap:anywhere]"
>
<span class="font-semibold text-text-primary">{{ asset.equipmentCode }}</span>
<span class="text-text-secondary">{{ asset.equipmentName }}</span>
</li>
</ul>
</div>
</div>
<span class="badge badge-neutral">
{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
{{ getChangeProcessingLabel(change.processing_status) }}
</span>
<span>处理人:{{ getChangeProcessorLabel(change) }}</span>
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
</div>
</div>
</div>
<div v-else class="empty-state mt-4">
本次同步没有变化明细。
</div>
</div>
</div>
<div v-else class="panel mt-4 text-sm text-text-tertiary">
暂时没有读取到这条消息对应的同步详情。
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/utils/format'
import type {
HrSyncBorrowedAssetPreview,
HrSyncChangeItem,
HrSyncChangeProcessingStatus,
HrSyncJobDetail,
HrSyncPersonAfterSnapshot,
NotificationItem
} from '@/types'
import {
getNotificationList,
markAllNotificationsAsRead,
markNotificationAsRead
} from '@/api/notifications'
import { getHrSyncJobDetail } from '@/api/sync'
import { useNotificationStore } from '@/stores/notifications'
import {
getNotificationActionLabel,
getNotificationRecordBadge,
isNotificationAttentionRequired
} from './notificationPresentation'
import MobileActionSheet from '@/components/MobileActionSheet.vue'
const notificationStore = useNotificationStore()
const notifications = ref<NotificationItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 12
const statusFilter = ref<'all' | 'unread'>('all')
const loading = ref(false)
const markAllLoading = ref(false)
const mobileActionNotification = ref<NotificationItem | null>(null)
const syncDetailOpen = ref(false)
const syncDetailLoading = ref(false)
const selectedSyncJob = ref<HrSyncJobDetail | null>(null)
const CHANGE_TYPE_LABELS: Record<string, string> = {
person_created: '新人入职',
person_deactivated: '人员离职/停用',
person_reactivated: '重新启用',
person_main_department_changed: '主部门变动',
person_departments_changed: '关联部门变动',
person_position_changed: '岗位变动',
department_created: '新增部门',
department_renamed: '部门名称变动',
department_hierarchy_changed: '部门层级变动',
department_deactivated: '部门停用',
department_reactivated: '部门重新启用',
equipment_department_unresolved: '资产部门待处理',
person_assets_returned: '资产已自动入库',
person_assets_reassigned: '资产归属已更新',
person_assets_review: '资产待核实',
person_assets_retry: '资产自动处理待重试',
equipment_auto_return_failed: '离职自动入库未执行'
}
const CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const CHANGE_PROCESSING_BADGES: Record<HrSyncChangeProcessingStatus, string> = {
pending: 'badge-warning',
processed: 'badge-success',
ignored: 'badge-neutral'
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const normalizeChangeProcessingStatus = (
status?: HrSyncChangeProcessingStatus | string | null
): HrSyncChangeProcessingStatus => {
if (status === 'processed' || status === 'ignored') {
return status
}
return 'pending'
}
const getJobStatusLabel = (status?: string) => (status === 'failed' ? '失败' : '成功')
const getTriggerModeLabel = (mode?: string) => (mode === 'scheduler' ? '定时执行' : '手动执行')
const getChangeProcessingLabel = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_LABELS[normalizeChangeProcessingStatus(status)]
const getChangeProcessingBadgeClass = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_BADGES[normalizeChangeProcessingStatus(status)]
const getChangeProcessorLabel = (change: HrSyncChangeItem) => {
const processedBy = change.processed_by_name || change.processed_by
if (processedBy) return processedBy
if (change.processing_status === 'processed' &&
(change.change_type === 'person_assets_returned' || change.change_type === 'person_assets_reassigned')) return '系统'
return '未处理'
}
const getChangeDisplayTitle = (change: HrSyncChangeItem) => {
const personChangeTitleMap: Record<string, string> = {
person_created: '新人入职',
person_deactivated: '人员离职/停用',
person_reactivated: '人员重新启用',
person_main_department_changed: '主部门变动',
person_departments_changed: '关联部门变动',
person_position_changed: '岗位变动'
}
const titlePrefix = personChangeTitleMap[change.change_type]
if (!titlePrefix) {
return change.title
}
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
const fallbackSnapshot = (change.before_snapshot || {}) as HrSyncPersonAfterSnapshot
const displayName =
afterSnapshot?.displayName ||
afterSnapshot?.nickname ||
afterSnapshot?.realName ||
fallbackSnapshot?.displayName ||
fallbackSnapshot?.nickname ||
fallbackSnapshot?.realName
return displayName ? `${titlePrefix}:${displayName}` : change.title
}
const extractDetailAssets = (detail?: string | null): HrSyncBorrowedAssetPreview[] => {
if (!detail) {
return []
}
const matched = detail.match(/名下(?:待处理)?资产:([^。]+)/)
if (!matched?.[1]) {
return []
}
return matched[1]
.split('、')
.map((rawItem) => {
const normalizedItem = rawItem.replace(/\s*等\s*\d+\s*项$/, '').trim()
const separatorIndex = normalizedItem.indexOf('/')
if (separatorIndex <= 0) {
return null
}
const equipmentCode = normalizedItem.slice(0, separatorIndex).trim()
const equipmentName = normalizedItem.slice(separatorIndex + 1).trim()
if (!equipmentCode || !equipmentName) {
return null
}
return {
equipmentCode,
equipmentName
}
})
.filter((asset): asset is HrSyncBorrowedAssetPreview => Boolean(asset))
}
const getChangeAssets = (change: HrSyncChangeItem): HrSyncBorrowedAssetPreview[] => {
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
const assets = afterSnapshot?.assetOutcome?.assets ?? afterSnapshot?.assets
if (Array.isArray(assets) && assets.length > 0) {
return assets.filter(
(asset) => Boolean(asset?.equipmentCode) && Boolean(asset?.equipmentName)
)
}
return extractDetailAssets(change.detail)
}
const summaryPairs = (item: {
counts?: Record<string, number>
metadata?: {
counts?: Record<string, number>
} | null
}) =>
Object.entries(item.counts || item.metadata?.counts || {})
.filter(([, count]) => Number(count) > 0)
.map(([key, count]) => ({
label: CHANGE_TYPE_LABELS[key] || key,
count: Number(count)
}))
const fetchNotifications = async () => {
loading.value = true
try {
const response = await getNotificationList({
page: currentPage.value,
pageSize,
status: statusFilter.value
})
if (response.data) {
notifications.value = response.data.list
total.value = response.data.total
notificationStore.setUnreadCount(response.data.unread_count || 0)
}
} catch (error: any) {
alert(error?.message || '获取系统记录失败')
} finally {
loading.value = false
}
}
const switchFilter = (value: 'all' | 'unread') => {
statusFilter.value = value
currentPage.value = 1
fetchNotifications()
}
const handleMarkRead = async (item: NotificationItem) => {
try {
await markNotificationAsRead(item.id)
item.is_read = true
item.read_at = new Date().toISOString()
await notificationStore.fetchUnreadCount()
if (statusFilter.value === 'unread') {
fetchNotifications()
}
} catch (error: any) {
alert(error?.message || '标为已查看失败')
}
}
const handleMarkAllRead = async () => {
markAllLoading.value = true
try {
await markAllNotificationsAsRead()
await notificationStore.fetchUnreadCount()
await fetchNotifications()
} catch (error: any) {
alert(error?.message || '全部标为已查看失败')
} finally {
markAllLoading.value = false
}
}
const handleMobileMarkRead = async () => {
const item = mobileActionNotification.value
mobileActionNotification.value = null
if (item) await handleMarkRead(item)
}
const closeSyncDetail = () => {
syncDetailOpen.value = false
selectedSyncJob.value = null
}
const openSyncDetail = async (syncJobId: number) => {
syncDetailOpen.value = true
syncDetailLoading.value = true
try {
const response = await getHrSyncJobDetail(syncJobId)
selectedSyncJob.value = response.data || null
} catch (error: any) {
selectedSyncJob.value = null
alert(error?.message || '读取同步详情失败')
} finally {
syncDetailLoading.value = false
}
}
const openAction = async (item: NotificationItem) => {
if (!item.is_read) {
await handleMarkRead(item)
}
if (item.related_sync_job_id) {
await openSyncDetail(item.related_sync_job_id)
return
}
window.location.href = item.action_url || '/settings?tab=sync'
}
const handlePrevPage = () => {
if (currentPage.value <= 1) return
currentPage.value -= 1
fetchNotifications()
}
const handleNextPage = () => {
if (currentPage.value >= totalPages.value) return
currentPage.value += 1
fetchNotifications()
}
onMounted(async () => {
await notificationStore.fetchUnreadCount()
await fetchNotifications()
})
</script>