feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -16,7 +16,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleSkills.length"
|
||||
@@ -326,14 +325,6 @@ const pagedSkills = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleSkills.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleSkills.length} 条,每页 ${pageSize.value} 条,当前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="true"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleEmployees.length"
|
||||
@@ -225,14 +224,6 @@ const pagedEmployees = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return props.visibleEmployees.slice(start, start + pageSize.value)
|
||||
})
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleEmployees.length} 条,每页 ${pageSize.value} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
@@ -110,6 +110,26 @@
|
||||
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察。</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'finance_snapshot'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>财务经营快照</h4>
|
||||
<span>{{ summary.period || summary.month || '本期' }}</span>
|
||||
</div>
|
||||
<p class="run-product-copy">
|
||||
本次产物已刷新财务看板缓存,沉淀报销金额、预算使用、费用结构和高额单据等经营指标。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'reminder_scan'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>提醒与待办沉淀</h4>
|
||||
<span>{{ summary.reminder_count || summary.reminders || 0 }} 条</span>
|
||||
</div>
|
||||
<p class="run-product-copy">
|
||||
本次产物已生成审批提醒、预算编制提醒、报销逾期提醒和差旅申请闭环提醒。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="productKind === 'risk_clue'" class="run-product-section">
|
||||
<div class="run-product-section-head">
|
||||
<h4>待复核线索</h4>
|
||||
@@ -230,6 +250,12 @@ const productSubtitle = computed(() => {
|
||||
if (productKind.value === 'risk_graph') {
|
||||
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return '展示本次财务经营快照沉淀的预算、费用和报销统计。'
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return '展示本次定时提醒扫描生成的待办和触达结果。'
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return '展示本次画像巡检写入的员工画像快照摘要。'
|
||||
}
|
||||
@@ -245,6 +271,12 @@ const productBadge = computed(() => {
|
||||
if (productKind.value === 'risk_graph') {
|
||||
return '风险观察'
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return '财务快照'
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return '提醒事项'
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return '画像快照'
|
||||
}
|
||||
@@ -281,6 +313,25 @@ const metrics = computed(() => {
|
||||
buildMetric('图谱关系', payload.graph_edge_count)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'finance_snapshot') {
|
||||
return [
|
||||
buildMetric('报销单数', payload.claim_count ?? payload.claims ?? payload.total_claims),
|
||||
buildMetric(
|
||||
'报销金额',
|
||||
formatMoney(payload.claim_amount ?? payload.reimbursement_amount ?? payload.total_amount)
|
||||
),
|
||||
buildMetric('预算使用率', formatPercent(payload.budget_usage_rate ?? payload.budget_rate)),
|
||||
buildMetric('高额单据', payload.high_value_claim_count ?? payload.high_amount_claims)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'reminder_scan') {
|
||||
return [
|
||||
buildMetric('提醒人数', payload.recipient_count),
|
||||
buildMetric('提醒事项', payload.reminder_count),
|
||||
buildMetric('待审批', payload.approval_pending_count),
|
||||
buildMetric('逾期报销', payload.reimbursement_overdue_count)
|
||||
]
|
||||
}
|
||||
if (productKind.value === 'employee_profile') {
|
||||
return [
|
||||
buildMetric('目标员工', payload.target_employee_count),
|
||||
@@ -376,6 +427,23 @@ function formatWindowDays(value) {
|
||||
return days.length ? days.map((item) => `${item}天`).join(' / ') : '-'
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return '-'
|
||||
}
|
||||
return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return '-'
|
||||
}
|
||||
const percent = numericValue > 1 ? numericValue : numericValue * 100
|
||||
return `${Math.round(percent)}%`
|
||||
}
|
||||
|
||||
function observationGraphCount(item) {
|
||||
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="false"
|
||||
:summary="paginationSummary"
|
||||
:total="filteredRuns.length"
|
||||
@@ -303,6 +302,7 @@ import {
|
||||
import {
|
||||
formatWorkRecordDateTime,
|
||||
formatWorkRecordSummary,
|
||||
compactDigitalEmployeeWorkRecords,
|
||||
resolveWorkRecordModuleLabel,
|
||||
resolveWorkRecordSourceLabel,
|
||||
resolveWorkRecordStatusLabel,
|
||||
@@ -456,14 +456,6 @@ const visibleRuns = computed(() => {
|
||||
return filteredRuns.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
@@ -523,7 +515,7 @@ async function loadWorkRecords(showToast = false) {
|
||||
|
||||
try {
|
||||
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
|
||||
runs.value = Array.isArray(payload) ? payload : []
|
||||
runs.value = Array.isArray(payload) ? compactDigitalEmployeeWorkRecords(payload) : []
|
||||
emit('summary-change', {
|
||||
total: workRecordSummary.value.total,
|
||||
succeeded: workRecordSummary.value.succeeded,
|
||||
|
||||
@@ -191,41 +191,6 @@
|
||||
</div>
|
||||
|
||||
<div class="workbench-content-grid">
|
||||
<article class="panel workbench-card todo-panel">
|
||||
<div class="section-head">
|
||||
<div class="title-with-badge">
|
||||
<h2>我的待办</h2>
|
||||
<span class="soft-badge">{{ todoAlertCount }}</span>
|
||||
</div>
|
||||
<button type="button" class="link-action">全部待办 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="todo-list">
|
||||
<button
|
||||
v-for="item in visibleTodoItems"
|
||||
:key="item.title"
|
||||
type="button"
|
||||
class="todo-row"
|
||||
@click="openPromptAssistant(`帮我处理:${item.title},${item.description}`)"
|
||||
>
|
||||
<WorkbenchListIcon
|
||||
:icon-key="item.iconKey"
|
||||
:color="item.color"
|
||||
:accent="item.accent"
|
||||
/>
|
||||
<span class="todo-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.description }}</small>
|
||||
</span>
|
||||
<span class="todo-meta">
|
||||
<span class="todo-status" :class="`todo-status--${item.statusTone}`">{{ item.status }}</span>
|
||||
<small>{{ item.due }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
<article class="panel workbench-card progress-panel">
|
||||
<div class="section-head">
|
||||
<h2>费用进度</h2>
|
||||
@@ -238,7 +203,7 @@
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="progress-row"
|
||||
@click="openPromptAssistant(`查询 ${item.id} 的费用进度`)"
|
||||
@click="openWorkbenchTarget(item)"
|
||||
>
|
||||
<span class="progress-identity">
|
||||
<strong>{{ item.id }}</strong>
|
||||
@@ -247,17 +212,17 @@
|
||||
|
||||
<span class="progress-steps" aria-hidden="true">
|
||||
<span
|
||||
v-for="(step, index) in progressSteps"
|
||||
:key="step"
|
||||
v-for="step in item.steps"
|
||||
:key="step.label"
|
||||
class="progress-step"
|
||||
:class="{
|
||||
'is-done': index < item.activeStep,
|
||||
'is-current': index === item.activeStep,
|
||||
'is-future': index > item.activeStep
|
||||
'is-done': step.done,
|
||||
'is-current': step.current,
|
||||
'is-future': !step.done && !step.current
|
||||
}"
|
||||
>
|
||||
<i :class="index <= item.activeStep ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
||||
<small>{{ step }}</small>
|
||||
<i :class="step.done || step.current ? 'mdi mdi-check' : 'mdi mdi-minus'"></i>
|
||||
<small>{{ step.label }}</small>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -357,7 +322,6 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -365,11 +329,8 @@ import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposer
|
||||
import {
|
||||
buildExpenseStatItems,
|
||||
filterAssistantCapabilitiesForUser,
|
||||
progressItems,
|
||||
progressSteps,
|
||||
quickPromptItems,
|
||||
resolveWorkbenchCapabilityGridClass,
|
||||
todoItems,
|
||||
} from '../../data/personalWorkbench.js'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
@@ -394,7 +355,7 @@ const props = defineProps({
|
||||
workbenchSummary: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
@@ -494,9 +455,12 @@ const currentUserProfileKey = computed(() => {
|
||||
user.employee_no
|
||||
].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
||||
})
|
||||
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
|
||||
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
|
||||
const todoAlertCount = computed(() => visibleTodoItems.value.length)
|
||||
const visibleProgressItems = computed(() => {
|
||||
const rows = Array.isArray(props.workbenchSummary.progressItems)
|
||||
? props.workbenchSummary.progressItems
|
||||
: []
|
||||
return rows.slice(0, 5)
|
||||
})
|
||||
|
||||
function buildSelectedFileKey(file) {
|
||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||
@@ -625,6 +589,20 @@ function openPromptAssistant(prompt) {
|
||||
emitAssistant(payload)
|
||||
}
|
||||
|
||||
function openWorkbenchTarget(item) {
|
||||
const target = item?.target || {}
|
||||
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||
emit('open-document', {
|
||||
claimId: target.id,
|
||||
id: target.id || target.claimNo,
|
||||
claimNo: target.claimNo
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
|
||||
}
|
||||
|
||||
function openCapabilityAssistant(item) {
|
||||
if (pendingAction.value) {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
</svg>
|
||||
<template v-else>{{ idx + 1 }}</template>
|
||||
</span>
|
||||
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
||||
<span class="rank-copy">
|
||||
<span class="rank-name">{{ item.name || item.shortName }}</span>
|
||||
<small v-if="item.meta" class="rank-meta">{{ item.meta }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-area" role="img" :aria-label="ariaLabel"></div>
|
||||
@@ -90,7 +93,11 @@ const chartOptions = computed(() => ({
|
||||
fontWeight: 700
|
||||
},
|
||||
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
|
||||
formatter: (params) => `${params.marker}${params.name}: ${formatValue(params.value)}`
|
||||
formatter: (params) => {
|
||||
const item = resolvedItems.value.find((row) => (row.name || row.shortName) === params.name)
|
||||
const meta = item?.meta ? `<br/>${item.meta}` : ''
|
||||
return `${params.marker}${params.name}: ${formatValue(params.value)}${meta}`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
@@ -180,7 +187,8 @@ const formatValue = (value) => {
|
||||
}
|
||||
|
||||
.rank-labels {
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 min(34%, 150px);
|
||||
min-width: 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
@@ -214,10 +222,24 @@ const formatValue = (value) => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rank-meta {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<section class="risk-observation-dashboard">
|
||||
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
|
||||
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ loadingLabel }}</span>
|
||||
</div>
|
||||
<article class="panel dashboard-card risk-trend-panel">
|
||||
<div class="card-head">
|
||||
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<div class="risk-window-controls">
|
||||
<span v-if="lastUpdatedLabel" class="risk-refresh-label">{{ lastUpdatedLabel }}</span>
|
||||
<span class="risk-window-label">近 {{ dashboard.windowDays }} 天</span>
|
||||
<EnterpriseSelect
|
||||
class="risk-window-select"
|
||||
@@ -174,11 +179,29 @@ const props = defineProps({
|
||||
signalRanking: { type: Array, default: () => [] },
|
||||
dailyRows: { type: Array, default: () => [] },
|
||||
windowOptions: { type: Array, default: () => [] },
|
||||
activeWindowDays: { type: Number, default: 30 }
|
||||
activeWindowDays: { type: Number, default: 30 },
|
||||
lastUpdatedAt: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['update:windowDays'])
|
||||
|
||||
const router = useRouter()
|
||||
const loadingLabel = computed(() => (
|
||||
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
|
||||
))
|
||||
const lastUpdatedLabel = computed(() => {
|
||||
if (!props.lastUpdatedAt) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(props.lastUpdatedAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return `上次同步 ${date.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`
|
||||
})
|
||||
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
|
||||
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
|
||||
const dimensionGroups = computed(() => [
|
||||
@@ -315,12 +338,39 @@ function openClaim(item) {
|
||||
|
||||
<style scoped>
|
||||
.risk-observation-dashboard {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.risk-dashboard-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: rgba(248, 250, 252, .82);
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.risk-dashboard-loading-overlay i {
|
||||
color: var(--theme-primary);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.risk-observation-dashboard.is-loading .dashboard-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
@@ -359,6 +409,13 @@ function openClaim(item) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-refresh-label {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-window-controls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat }">
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
||||
<div class="title-group">
|
||||
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||
<h1>{{ currentView.title }}</h1>
|
||||
@@ -121,12 +121,73 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isWorkbench">
|
||||
<template v-else-if="isWorkbench">
|
||||
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
||||
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
|
||||
<i class="mdi mdi-bell-outline"></i>
|
||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||
</button>
|
||||
<div class="notification-wrap">
|
||||
<button
|
||||
class="topbar-icon-btn notification-btn"
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
:aria-expanded="notificationOpen"
|
||||
aria-haspopup="dialog"
|
||||
@click="notificationOpen = !notificationOpen"
|
||||
>
|
||||
<i class="mdi mdi-bell-outline"></i>
|
||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
|
||||
<header class="notification-head">
|
||||
<strong>通知</strong>
|
||||
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="notificationTab === 'unread'"
|
||||
:class="{ active: notificationTab === 'unread' }"
|
||||
@click="notificationTab = 'unread'"
|
||||
>
|
||||
未读 {{ unreadNotifications.length }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="notificationTab === 'read'"
|
||||
:class="{ active: notificationTab === 'read' }"
|
||||
@click="notificationTab = 'read'"
|
||||
>
|
||||
已读 {{ readNotifications.length }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeNotifications.length" class="notification-list">
|
||||
<button
|
||||
v-for="item in activeNotifications"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="notification-row"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<span class="notification-dot" :class="item.tone"></span>
|
||||
<span class="notification-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.description }}</small>
|
||||
<em>{{ item.time }}</em>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="notification-empty">
|
||||
<i class="mdi mdi-bell-check-outline"></i>
|
||||
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
||||
<i class="mdi mdi-help-circle-outline"></i>
|
||||
@@ -243,6 +304,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
workbenchSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -276,7 +341,8 @@ const emit = defineEmits([
|
||||
'update:overviewDashboard',
|
||||
'batchApprove',
|
||||
'openChat',
|
||||
'newApplication'
|
||||
'newApplication',
|
||||
'openDocument'
|
||||
])
|
||||
const isChat = computed(() => props.activeView === 'chat')
|
||||
const isOverview = computed(() => props.activeView === 'overview')
|
||||
@@ -294,10 +360,34 @@ const eyebrowLabel = computed(() => (
|
||||
))
|
||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const summary = props.documentSummary ?? {}
|
||||
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
|
||||
const summary = props.workbenchSummary ?? {}
|
||||
const count = Number(summary.unreadNotificationCount ?? 0)
|
||||
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
|
||||
})
|
||||
})
|
||||
const notificationOpen = ref(false)
|
||||
const notificationTab = ref('unread')
|
||||
const notificationItems = computed(() => (
|
||||
Array.isArray(props.workbenchSummary?.notifications)
|
||||
? props.workbenchSummary.notifications
|
||||
: []
|
||||
))
|
||||
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
|
||||
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
||||
const activeNotifications = computed(() => (
|
||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||
))
|
||||
|
||||
function openNotification(item) {
|
||||
notificationOpen.value = false
|
||||
const target = item?.target || {}
|
||||
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||
emit('openDocument', {
|
||||
claimId: target.id,
|
||||
id: target.id || target.claimNo,
|
||||
claimNo: target.claimNo
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestKpis = computed(() => {
|
||||
const summary = props.requestSummary ?? {}
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:pages="pages"
|
||||
:show-page-size="showPageSize"
|
||||
:summary="summary"
|
||||
:total="total"
|
||||
@@ -139,10 +138,6 @@ const props = defineProps({
|
||||
default: () => []
|
||||
},
|
||||
panel: { type: Boolean, default: true },
|
||||
pages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
retryLabel: { type: String, default: '重新加载' },
|
||||
searchable: { type: Boolean, default: false },
|
||||
searchPlaceholder: { type: String, default: '搜索' },
|
||||
|
||||
@@ -78,10 +78,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
pages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showPageSize: { type: Boolean, default: true },
|
||||
summary: { type: String, default: '' },
|
||||
total: { type: Number, default: 0 },
|
||||
@@ -106,7 +102,7 @@ const summaryText = computed(() => {
|
||||
return props.summary
|
||||
}
|
||||
|
||||
return `共 ${props.total} 条,当前第 ${props.currentPage} 页`
|
||||
return `共 ${props.total} 条,当前第 ${props.currentPage} / ${props.totalPages} 页`
|
||||
})
|
||||
|
||||
function setPage(page) {
|
||||
@@ -142,3 +138,140 @@ watch(
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-foot.enterprise-pagination {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-summary {
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button.active {
|
||||
background: var(--theme-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-ellipsis {
|
||||
width: 28px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-tools {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-size-select {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump input {
|
||||
width: 54px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-jump input:focus {
|
||||
border-color: var(--theme-primary);
|
||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.list-foot.enterprise-pagination {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.enterprise-pagination .pager button,
|
||||
.enterprise-pagination .page-ellipsis {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.enterprise-pagination .page-tools {
|
||||
justify-self: stretch;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user