- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款), 守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向 - 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部 - 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围 - 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单 - 新增 role_home_flow e2e,更新 system_management 普通用户断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
652 lines
17 KiB
Vue
652 lines
17 KiB
Vue
<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>
|