PayrollManagementSystem/frontend/src/views/dashboard/index.vue
liuwenxin e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除
  纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护)
- AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐
- index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、
  形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖
  Element Plus 主色
- 登录页、业务页、结算页、工作台、审批页等视图随新变量适配

验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:45:54 +08:00

671 lines
18 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))
}
}
/**
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
* 150px 高的图压满标签。
*/
function axisCommon(trend: TrendPoint[]) {
return {
xAxis: {
type: 'category' as const,
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', fontSize: 9, margin: 6 },
},
yAxis: {
type: 'value' as const,
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
},
}
}
/**
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
* color-mix 与 canvas 渐变对象在 SVG renderer 下表现一致,故直接用 echarts 的
* linear 渐变描述对象,不依赖 canvas API。
*/
function verticalGradient(top: string, bottom: string) {
return {
type: 'linear' as const,
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: top },
{ offset: 1, color: bottom },
],
}
}
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) },
...axisCommon(trend),
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: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
},
],
}
}
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
const lastIndex = trend.length - 1
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) },
...axisCommon(trend),
series: [
{
name: '回款',
type: 'bar',
barMaxWidth: 22,
data: trend.map((item, index) => ({
value: Number(item.receipt_total ?? 0),
itemStyle: {
color:
index === lastIndex
? verticalGradient('#3a58bc', '#2e4ba8')
: verticalGradient('#d5def3', '#e8edf8'),
borderRadius: [3, 3, 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/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
.bento {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 102px;
gap: 14px;
}
.bento-card {
position: relative;
padding: 16px 18px;
overflow: hidden;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
transition:
transform 180ms var(--ys-ease),
box-shadow 180ms var(--ys-ease);
/* 交错入场:45ms 一檔,卡片依次上浮,避免整屏同时闪一下 */
animation: ys-rise 320ms var(--ys-ease) backwards;
}
.bento > :nth-child(2) {
animation-delay: 45ms;
}
.bento > :nth-child(3) {
animation-delay: 90ms;
}
.bento > :nth-child(4) {
animation-delay: 135ms;
}
.bento > :nth-child(5) {
animation-delay: 180ms;
}
.bento > :nth-child(6) {
animation-delay: 225ms;
}
.bento > :nth-child(7) {
animation-delay: 270ms;
}
.bento > :nth-child(8) {
animation-delay: 315ms;
}
.bento-card:hover {
transform: translateY(-2px);
box-shadow: var(--ys-shadow-raise);
}
.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: 108px;
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);
cursor: pointer;
background: var(--ys-indigo-wash);
border: 1px solid transparent;
border-radius: var(--ys-radius-sm);
transition:
border-color 150ms var(--ys-ease),
background-color 150ms var(--ys-ease);
}
.b-op:hover {
background: #e6ecf9;
border-color: rgba(46, 75, 168, 0.35);
}
.b-op:focus-visible {
outline: 2px solid var(--ys-indigo);
outline-offset: 2px;
}
@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>