Files
PayrollManagementSystem/frontend/src/views/dashboard/index.vue
T
liuwenxinandClaude Code 39a3fa7796 fix(dashboard): 修复普通用户落地页与待办角色过滤
- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:45:03 +08:00

652 lines
17 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.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import type { ECharts, EChartsCoreOption } from 'echarts/core'
import * as dashboardApi from '@/api/dashboard'
import type { DashboardData, TrendPoint } from '@/api/dashboard'
import { handledError } from '@/utils/error'
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
/**
* 工作台(M4 ⑧):经营绩效总览 / 待办事项 / 风险预警 / 最近动态。
*
* - 四区块由一次聚合请求返回(后端实时派生,见 dashboard_service 注释)
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
* - ⑥ T+ 同步未建:同步失败待办/同步预警显示「模块未启用」,不显示 0 条
*/
const router = useRouter()
const CHART_FONT =
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
const SETTLEMENT_STATUS_LABELS: Record<number, string> = {
1: '草稿',
2: '待审批',
3: '已锁定',
4: '已驳回',
}
const ORDER_TYPE_LABELS: Record<number, string> = {
1: '二次分配方案',
2: '月度结算',
3: '调整单',
}
const month = ref(currentMonth())
const data = ref<DashboardData | null>(null)
const loading = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0
const perfChartRef = ref<HTMLDivElement | null>(null)
const receiptChartRef = ref<HTMLDivElement | null>(null)
let perfChart: ECharts | null = null
let receiptChart: ECharts | null = null
function currentMonth(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
try {
const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
data.value = result
await nextTick()
renderCharts()
} catch (error) {
if (seq === loadSeq) handledError(error)
} finally {
if (seq === loadSeq) loading.value = false
}
}
function renderCharts(): void {
const trend = data.value?.overview.trend ?? []
if (perfChartRef.value) {
perfChart ??= echarts.init(perfChartRef.value, undefined, { renderer: 'svg' })
perfChart.setOption(performanceOption(trend))
}
if (receiptChartRef.value) {
receiptChart ??= echarts.init(receiptChartRef.value, undefined, { renderer: 'svg' })
receiptChart.setOption(receiptOption(trend))
}
}
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
series: [
{
name: '绩效',
type: 'line',
smooth: 0.4,
symbol: 'none',
data: trend.map((item) => Number(item.performance_total ?? 0)),
lineStyle: { color: '#2e4ba8', width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(46,75,168,.22)' },
{ offset: 1, color: 'rgba(46,75,168,0)' },
],
},
},
},
],
}
}
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
return {
textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
xAxis: {
type: 'category',
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
yAxis: {
type: 'value',
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2' },
},
series: [
{
name: '回款',
type: 'bar',
data: trend.map((item) => Number(item.receipt_total ?? 0)),
barMaxWidth: 22,
itemStyle: { color: '#2e4ba8', borderRadius: [4, 4, 0, 0] },
},
],
}
}
function handleResize(): void {
perfChart?.resize()
receiptChart?.resize()
}
onMounted(() => {
void load()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
perfChart?.dispose()
receiptChart?.dispose()
})
const activityModule = ref('')
const activityModules = computed(() => {
const modules = new Set((data.value?.activities ?? []).map((item) => item.module))
return [...modules]
})
const filteredActivities = computed(() =>
(data.value?.activities ?? []).filter(
(item) => !activityModule.value || item.module === activityModule.value,
),
)
function go(path: string | null): void {
if (path) void router.push(path)
}
function formatTime(value: string): string {
return value.replace('T', ' ').slice(0, 16)
}
</script>
<template>
<div v-loading="loading">
<div class="page-head">
<h1 class="page-title">工作台</h1>
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
<span class="waterline"></span>
</div>
<div class="toolbar">
<el-date-picker
v-model="month"
type="month"
value-format="YYYY-MM"
:clearable="false"
style="width: 140px"
@change="load"
/>
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
</div>
<div class="bento">
<!-- 经营绩效总览:KPI -->
<div class="bento-card">
<div class="b-eyebrow">本月绩效总额</div>
<div class="b-num">{{ data?.overview.performance_total ?? '—' }}</div>
</div>
<div class="bento-card">
<div class="b-eyebrow">本月应付绩效</div>
<div class="b-num">{{ data?.overview.payable_total ?? '—' }}</div>
</div>
<div class="bento-card">
<div class="b-eyebrow">核算人数</div>
<div class="b-num">{{ data?.overview.person_count ?? '—' }}<small>人</small></div>
</div>
<div class="bento-card">
<div class="b-eyebrow">营销团队</div>
<div class="b-num">{{ data?.overview.team_count ?? '—' }}<small>个</small></div>
<div class="b-sub">
月结:
{{
data?.overview.settlement.exists
? SETTLEMENT_STATUS_LABELS[data.overview.settlement.status ?? 0] ?? '—'
: '未生成'
}}
</div>
</div>
<!-- 经营绩效总览:图表 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月绩效</div>
<div ref="perfChartRef" class="chart"></div>
</div>
<div class="bento-card b-2x2">
<div class="b-eyebrow">近 6 个月回款</div>
<div ref="receiptChartRef" class="chart"></div>
</div>
<!-- 待办事项 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">
待办事项
<span class="b-count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
</div>
<div v-if="data?.todos.pending_approvals.length" class="todo-list">
<button
v-for="item in data.todos.pending_approvals"
:key="item.order_id"
class="todo-row"
type="button"
@click="go('/system/approvals')"
>
<span class="todo-title">
{{ ORDER_TYPE_LABELS[item.order_type] ?? '审批' }} · {{ item.order_no }}
</span>
<span class="todo-meta">{{ item.submitter_name ?? '—' }} · {{ formatTime(item.create_time) }}</span>
</button>
</div>
<div v-else class="b-empty-mini">无待审批事项</div>
<div class="b-eyebrow b-eyebrow-gap">
回款未指定批次
<span class="b-count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
</div>
<div v-if="data?.todos.unassigned_receipts.length" class="todo-list">
<button
v-for="item in data.todos.unassigned_receipts"
:key="item.receipt_id"
class="todo-row"
type="button"
@click="go('/business/receipts')"
>
<span class="todo-title">{{ item.receipt_batch_no }} · {{ item.amount }}</span>
<span class="todo-meta">业务 {{ item.business_no ?? item.business_id }} · {{ item.receipt_date }}</span>
</button>
</div>
<div v-else class="b-empty-mini">无未指定批次的回款</div>
<div class="b-eyebrow b-eyebrow-gap">T+ 同步失败</div>
<div class="b-empty-mini">
{{ data?.todos.sync_available ? `${data.todos.sync_failure_total} 条待处置` : '同步模块未启用(待 T+ 对接)' }}
</div>
</div>
<!-- 风险预警 -->
<div class="bento-card b-2x2">
<div class="b-eyebrow">
风险预警
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
</div>
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
预警已关闭(参数 warning.enabled=0)
</div>
<div v-else-if="data?.warnings.items.length" class="warning-list">
<button
v-for="item in data.warnings.items"
:key="`${item.code}-${item.object_id}`"
class="warning-row"
type="button"
@click="go(item.link)"
>
<el-tag
size="small"
:type="item.level === 'danger' ? 'danger' : 'warning'"
disable-transitions
>
{{ item.level === 'danger' ? '资金' : '时效' }}
</el-tag>
<span class="warning-title">{{ item.title }}</span>
<span class="warning-detail">{{ item.detail }}</span>
</button>
</div>
<div v-else class="b-empty-mini">
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
</div>
<div
v-if="data && data.warnings.total > data.warnings.items.length"
class="b-empty-mini"
>
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
</div>
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
<div class="b-empty-mini">
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
</div>
</div>
<!-- 最近动态 -->
<div class="bento-card b-full">
<div class="b-eyebrow">
最近动态
<el-select
v-model="activityModule"
size="small"
clearable
placeholder="全部模块"
style="width: 140px; margin-left: 12px"
>
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
</el-select>
</div>
<div v-if="filteredActivities.length" class="activity-list">
<div v-for="item in filteredActivities" :key="item.id" class="activity-row">
<span class="activity-time">{{ formatTime(item.create_time) }}</span>
<el-tag size="small" :type="item.success === 1 ? 'info' : 'danger'" disable-transitions>
{{ item.module }} · {{ item.action }}
</el-tag>
<span class="activity-summary">{{ item.summary }}</span>
<span class="activity-user">{{ item.username ?? '—' }}</span>
</div>
</div>
<div v-else class="b-empty-mini">暂无动态</div>
</div>
<!-- 常用操作 -->
<div class="bento-card b-full">
<div class="b-eyebrow">常用操作</div>
<div class="b-links">
<button class="b-op" type="button" @click="go('/business/receipts')">录入回款</button>
<button class="b-op" type="button" @click="go('/calc/ledger')">绩效台账</button>
<button class="b-op" type="button" @click="go('/settlements')">月度结算</button>
<button class="b-op" type="button" @click="go('/reports/personal-detail')">
报表统计
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-head {
margin-bottom: 18px;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--ys-ink);
}
.page-desc {
margin: 6px 0 0;
font-size: 13px;
color: var(--ys-ink-3);
}
.waterline {
display: block;
width: 46px;
height: 2px;
margin-top: 12px;
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.toolbar-note {
font-size: 12px;
color: var(--ys-ink-3);
}
.bento {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 102px;
gap: 14px;
}
.bento-card {
position: relative;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(28, 35, 51, 0.04),
0 10px 24px -18px rgba(28, 35, 51, 0.18);
transition: transform 0.18s ease, box-shadow 0.18s ease;
overflow: hidden;
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow:
0 2px 4px rgba(28, 35, 51, 0.05),
0 16px 30px -18px rgba(28, 35, 51, 0.22);
}
.b-2x2 {
grid-column: span 2;
grid-row: span 2;
}
.b-full {
grid-column: span 4;
}
.b-eyebrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ys-ink-3);
}
.b-eyebrow-gap {
margin-top: 12px;
}
.b-count {
min-width: 20px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
text-align: center;
color: var(--ys-indigo);
background: var(--ys-indigo-wash);
border-radius: 9px;
}
.b-num {
margin-top: 10px;
font-size: 26px;
font-weight: 600;
color: var(--ys-ink);
font-variant-numeric: tabular-nums;
}
.b-num small {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: var(--ys-ink-3);
}
.b-sub {
margin-top: 4px;
font-size: 12px;
color: var(--ys-ink-3);
}
.b-empty-mini {
margin-top: 8px;
font-size: 12px;
color: var(--ys-ink-3);
}
.chart {
height: 150px;
margin-top: 6px;
}
.todo-list,
.warning-list {
max-height: 178px;
margin-top: 8px;
overflow-y: auto;
}
.todo-row,
.warning-row {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 6px 4px;
border: none;
border-bottom: 1px dashed var(--ys-line);
background: none;
text-align: left;
cursor: pointer;
}
.todo-row:hover,
.warning-row:hover {
background: rgba(46, 75, 168, 0.04);
}
.todo-title {
font-size: 13px;
color: var(--ys-ink);
}
.todo-meta {
font-size: 11px;
color: var(--ys-ink-3);
}
.warning-row {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 8px;
align-items: center;
}
.warning-title {
font-size: 13px;
color: var(--ys-ink);
}
.warning-detail {
grid-column: 2;
font-size: 11px;
color: var(--ys-ink-3);
}
.activity-list {
max-height: 150px;
margin-top: 8px;
overflow-y: auto;
}
.activity-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
border-bottom: 1px dashed var(--ys-line);
font-size: 12px;
}
.activity-time {
width: 118px;
color: var(--ys-ink-3);
font-variant-numeric: tabular-nums;
}
.activity-summary {
flex: 1;
color: var(--ys-ink-2);
}
.activity-user {
color: var(--ys-ink-3);
}
.b-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.b-op {
padding: 6px 14px;
font-size: 13px;
color: var(--ys-indigo);
background: var(--ys-indigo-wash);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.b-op:hover {
border-color: var(--ys-indigo);
}
@media (max-width: 1024px) {
.bento {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.b-full {
grid-column: span 2;
}
}
@media (max-width: 640px) {
.bento {
grid-template-columns: 1fr;
}
.b-2x2,
.b-full {
grid-column: span 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bento-card {
transition: none;
}
.bento-card:hover {
transform: none;
}
}
</style>