1091 lines
35 KiB
Vue
1091 lines
35 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, 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 { Refresh, ArrowRight } from '@element-plus/icons-vue'
|
||
|
||
import * as dashboardApi from '@/api/dashboard'
|
||
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
import { formatDateTime } from '@/utils/format'
|
||
import { useUserStore } from '@/store/user'
|
||
|
||
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||
|
||
/**
|
||
* 工作台(M4 ⑧):经营绩效总览 / 待办事项 / 风险预警 / 最近动态。
|
||
*
|
||
* - 总览/待办/预警由一次聚合请求返回(后端实时派生,见 dashboard_service 注释)
|
||
* - **最近动态走独立分页端点**:它是操作日志全量,可能很长;塞进聚合响应
|
||
* 只能截断前 20 条。翻页只重取这张列表,不重算其余三区块
|
||
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
|
||
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
|
||
* - 同步采集记录与当前读取/自动采集开关分别展示
|
||
*
|
||
* 版式口径(本轮重排):
|
||
* - 旧版把所有卡片塞进一个 `grid-auto-rows: 102px` 的等高网格,列表容器
|
||
* 被父级 overflow 裁到只剩一行。现改为按区块分行:KPI / 趋势图 / 待办+预警 /
|
||
* 动态 / 常用操作,每个区块独立自适应高度,列表区用 flex:1 撑满并内部滚动。
|
||
* - 列表行压成单行(标题 + 右侧元信息),行高从约 47px 降到 30px,
|
||
* 同样高度下可见条数从 1 条提升到 4~5 条。
|
||
*/
|
||
|
||
const router = useRouter()
|
||
const user = useUserStore()
|
||
const approvalEnabled = computed(() => user.userInfo?.payroll_approval_enabled === true)
|
||
|
||
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 SYNC_STATUS_LABELS: Record<string, string> = {
|
||
SUCCEEDED: '最近采集成功',
|
||
FAILED: '最近采集失败',
|
||
PARTIAL: '最近采集部分失败',
|
||
QUEUED: '采集排队中',
|
||
RUNNING: '正在采集',
|
||
}
|
||
|
||
const month = ref(currentMonth())
|
||
const data = ref<DashboardData | null>(null)
|
||
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
|
||
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
|
||
const loading = ref(false)
|
||
const failed = 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
|
||
/**
|
||
* 卡片高度随断点与内容变化,窗口 resize 事件不覆盖这类重排,
|
||
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
|
||
*/
|
||
let chartObserver: ResizeObserver | null = null
|
||
let resizeFrame: number | undefined
|
||
|
||
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
|
||
failed.value = false
|
||
try {
|
||
const result = await dashboardApi.getDashboard({ month: month.value })
|
||
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
||
data.value = result
|
||
await nextTick()
|
||
renderCharts()
|
||
} catch (error) {
|
||
if (seq === loadSeq) { failed.value = true; handledError(error) }
|
||
} finally {
|
||
if (seq === loadSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
function renderCharts(): void {
|
||
const trend = data.value?.overview.trend ?? []
|
||
if (perfChartRef.value && hasPerformanceTrend.value) {
|
||
perfChart ??= echarts.init(perfChartRef.value, undefined, { renderer: 'svg' })
|
||
perfChart.setOption(performanceOption(trend))
|
||
}
|
||
if (receiptChartRef.value && hasReceiptTrend.value) {
|
||
receiptChart ??= echarts.init(receiptChartRef.value, undefined, { renderer: 'svg' })
|
||
receiptChart.setOption(receiptOption(trend))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
|
||
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
|
||
* 190px 高的图压满标签。
|
||
*/
|
||
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: '#657086', fontSize: 11, margin: 8 },
|
||
},
|
||
yAxis: {
|
||
type: 'value' as const,
|
||
splitNumber: 3,
|
||
splitLine: { show: true, lineStyle: { color: '#edf0f5', type: 'dashed' as const } },
|
||
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
|
||
* 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 chartValue(value: unknown): string {
|
||
if (value == null || value === '-') return '暂无记录'
|
||
const number = Number(value)
|
||
return Number.isFinite(number) ? number.toFixed(2) : '暂无记录'
|
||
}
|
||
|
||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||
return {
|
||
animation: false,
|
||
textStyle: { fontFamily: CHART_FONT },
|
||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||
...axisCommon(trend),
|
||
series: [
|
||
{
|
||
name: '绩效',
|
||
type: 'line',
|
||
smooth: 0.4,
|
||
symbol: 'none',
|
||
data: trend.map((item) => item.performance_total == null ? null : Number(item.performance_total)),
|
||
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 {
|
||
animation: false,
|
||
textStyle: { fontFamily: CHART_FONT },
|
||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||
...axisCommon(trend),
|
||
series: [
|
||
{
|
||
name: '回款',
|
||
type: 'bar',
|
||
barMaxWidth: 22,
|
||
data: trend.map((item, index) => ({
|
||
value: item.receipt_total == null ? null : Number(item.receipt_total),
|
||
itemStyle: {
|
||
color:
|
||
index === lastIndex
|
||
? verticalGradient('#3a58bc', '#2e4ba8')
|
||
: verticalGradient('#d5def3', '#e8edf8'),
|
||
borderRadius: [3, 3, 0, 0],
|
||
},
|
||
})),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function handleResize(): void {
|
||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||
resizeFrame = requestAnimationFrame(() => {
|
||
resizeFrame = undefined
|
||
perfChart?.resize()
|
||
receiptChart?.resize()
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
void loadActivities()
|
||
window.addEventListener('resize', handleResize)
|
||
chartObserver = new ResizeObserver(handleResize)
|
||
if (perfChartRef.value) chartObserver.observe(perfChartRef.value)
|
||
if (receiptChartRef.value) chartObserver.observe(receiptChartRef.value)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
chartObserver?.disconnect()
|
||
chartObserver = null
|
||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||
perfChart?.dispose()
|
||
receiptChart?.dispose()
|
||
})
|
||
|
||
const activityFilters = reactive({
|
||
module: undefined as string | undefined,
|
||
})
|
||
/** 模块筛选项:由服务端随列表返回(全量模块名,不随翻页变化) */
|
||
const activityModules = ref<string[]>([])
|
||
|
||
const {
|
||
records: activities,
|
||
total: activityTotal,
|
||
pageNum: activityPageNum,
|
||
pageSize: activityPageSize,
|
||
loading: activityLoading,
|
||
loadFailed: activityFailed,
|
||
load: loadActivities,
|
||
search: searchActivities,
|
||
onPageChange: onActivityPageChange,
|
||
onSizeChange: onActivityPageSizeChange,
|
||
} = usePagedList<ActivityItem, { module?: string }, ActivityPage>(
|
||
// 模块下拉清空时 Element Plus 可能回传 '' 或 undefined —— 统一成不传,
|
||
// 避免把空串当筛选条件发到后端
|
||
(params) =>
|
||
dashboardApi.listDashboardActivities({
|
||
pageNum: params.pageNum,
|
||
pageSize: params.pageSize,
|
||
module: params.module || undefined,
|
||
}),
|
||
() => ({ module: activityFilters.module }),
|
||
(page) => {
|
||
activityModules.value = page.modules
|
||
},
|
||
)
|
||
|
||
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" class="workbench">
|
||
<header class="page-head dashboard-head">
|
||
<div>
|
||
<h1 class="page-title">工作台</h1>
|
||
<p class="page-desc">查看本月绩效与回款,处理待办并核对业务风险。</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
<div class="dashboard-filter">
|
||
<span>统计月份</span>
|
||
<el-date-picker
|
||
v-model="month"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
:clearable="false"
|
||
style="width: 140px"
|
||
aria-label="统计月份"
|
||
@change="load"
|
||
/>
|
||
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||
</div>
|
||
</header>
|
||
<el-alert v-if="failed" class="dashboard-notice" title="工作台更新失败,显示内容可能不是最新结果" type="warning" :closable="false" show-icon><el-button link @click="load">重新加载工作台</el-button></el-alert>
|
||
<p v-if="data?.overview.note" class="overview-note">{{ data.overview.note }}</p>
|
||
|
||
<!-- ① 经营总览:KPI 四格 -->
|
||
<section class="kpi-row" aria-label="经营总览指标">
|
||
<div class="kpi-card">
|
||
<div class="kpi-label">本月绩效总额</div>
|
||
<div class="kpi-value">
|
||
{{ data?.overview.performance_total ?? '—' }}
|
||
<small v-if="data?.overview.performance_total">元</small>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label">本月应付绩效</div>
|
||
<div class="kpi-value">
|
||
{{ data?.overview.payable_total ?? '—' }}
|
||
<small v-if="data?.overview.payable_total">元</small>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label">核算人数</div>
|
||
<div class="kpi-value">
|
||
{{ data?.overview.person_count ?? '—' }}<small>人</small>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label">营销团队</div>
|
||
<div class="kpi-value">
|
||
{{ data?.overview.team_count ?? '—' }}<small>个</small>
|
||
</div>
|
||
<div class="kpi-foot">
|
||
月结:
|
||
{{
|
||
data?.overview.settlement.exists
|
||
? SETTLEMENT_STATUS_LABELS[data.overview.settlement.status ?? 0] ?? '—'
|
||
: '未生成'
|
||
}}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ② 近 6 个月趋势 -->
|
||
<section class="grid-2" aria-label="近六个月趋势">
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">近 6 个月绩效</span>
|
||
<span class="panel-hint">按台账归属月</span>
|
||
</div>
|
||
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart" role="img" aria-label="近六个月绩效趋势,按台账归属月统计"></div>
|
||
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
|
||
</div>
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="panel-title">近 6 个月回款</span>
|
||
<span class="panel-hint">按到账月</span>
|
||
</div>
|
||
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart" role="img" aria-label="近六个月回款趋势,按到账月统计"></div>
|
||
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ③ 待办事项 + 风险预警 -->
|
||
<section class="grid-2" aria-label="待办与风险">
|
||
<div class="panel panel--list">
|
||
<div class="panel-head">
|
||
<span class="panel-title">待办事项</span>
|
||
<span class="panel-hint">点击条目进入对应模块</span>
|
||
</div>
|
||
|
||
<div v-if="approvalEnabled" class="list-block">
|
||
<div class="list-block__head">
|
||
<span class="list-block__title">待审批</span>
|
||
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||
<button v-if="data?.todos.pending_approval_total" class="list-block__more" type="button" @click="go('/system/approvals')">
|
||
全部 →
|
||
</button>
|
||
</div>
|
||
<div v-if="data?.todos.pending_approvals.length" class="list-scroll">
|
||
<button
|
||
v-for="item in data.todos.pending_approvals"
|
||
:key="item.order_id"
|
||
class="row"
|
||
type="button"
|
||
@click="go('/system/approvals')"
|
||
>
|
||
<span class="row__main">
|
||
{{ ORDER_TYPE_LABELS[item.order_type] ?? '审批' }} · {{ item.order_no }}
|
||
</span>
|
||
<span class="row__meta">
|
||
{{ item.submitter_name ?? '—' }} · {{ formatTime(item.create_time) }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="list-empty">无待审批事项</div>
|
||
</div>
|
||
<div v-else class="status-strip">
|
||
<span class="status-strip__label">工资审核</span>
|
||
<span class="status-strip__value">当前已关闭,工资生成后直接完成</span>
|
||
</div>
|
||
|
||
<div class="list-block">
|
||
<div class="list-block__head">
|
||
<span class="list-block__title">未指定批次的回款(供核对)</span>
|
||
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||
<button v-if="data?.todos.unassigned_receipt_total" class="list-block__more" type="button" @click="go('/business/receipts')">
|
||
全部 →
|
||
</button>
|
||
</div>
|
||
<div v-if="data?.todos.unassigned_receipts.length" class="list-scroll">
|
||
<button
|
||
v-for="item in data.todos.unassigned_receipts"
|
||
:key="item.receipt_id"
|
||
class="row"
|
||
type="button"
|
||
@click="go('/business/receipts')"
|
||
>
|
||
<span class="row__main">{{ item.receipt_batch_no }} · {{ item.amount }}</span>
|
||
<span class="row__meta">
|
||
业务 {{ item.business_no ?? item.business_id }} · {{ item.receipt_date }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="list-empty">无未指定批次的回款</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="panel panel--list">
|
||
<div class="panel-head">
|
||
<span class="panel-title">风险预警</span>
|
||
<span class="panel-count">{{ data?.warnings.total ?? 0 }}</span>
|
||
</div>
|
||
|
||
<div v-if="data && !data.warnings.enabled" class="list-empty list-empty--block">
|
||
业务风险预警已关闭,可在计提参数中调整。
|
||
</div>
|
||
<div v-else-if="data?.warnings.items.length" class="list-scroll">
|
||
<button
|
||
v-for="item in data.warnings.items"
|
||
:key="`${item.code}-${item.object_id}`"
|
||
class="row row--warn"
|
||
type="button"
|
||
@click="go(item.link)"
|
||
>
|
||
<span class="row__tag">
|
||
<el-tag
|
||
size="small"
|
||
:type="item.level === 'danger' ? 'danger' : 'warning'"
|
||
disable-transitions
|
||
>
|
||
{{ item.level === 'danger' ? '资金' : '时效' }}
|
||
</el-tag>
|
||
</span>
|
||
<span class="row__main" :title="item.title">{{ item.title }}</span>
|
||
<span class="row__meta" :title="item.detail">{{ item.detail }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="list-empty list-empty--block">
|
||
暂无风险预警。回款逾期提醒:{{ data?.warnings.receipt_overdue_days ?? 90 }} 天。
|
||
<template v-if="approvalEnabled">审核超时提醒:{{ data?.warnings.approval_timeout_days ?? 3 }} 天。</template>
|
||
</div>
|
||
|
||
<div
|
||
v-if="data && data.warnings.total > data.warnings.items.length"
|
||
class="list-more"
|
||
>
|
||
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<section class="sync-context" aria-label="T+ 数据采集状态">
|
||
<strong>T+ 数据采集</strong>
|
||
<template v-if="data?.sync.available">
|
||
<span class="sync-context__state">{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }}</span>
|
||
<span>{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }}</span>
|
||
<span>{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}</span>
|
||
<span>最近成功:{{ formatDateTime(data.sync.last_success_at, '尚无成功记录') }}</span>
|
||
<span v-if="data.sync.historical_failure_total">历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
||
</template>
|
||
<span v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</span>
|
||
<el-button v-if="user.hasPermission('tplus:view')" link type="primary" :icon="ArrowRight" @click="go('/system/tplus')">查看同步</el-button>
|
||
</section>
|
||
|
||
<!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) -->
|
||
<section class="panel panel--activity" aria-label="最近动态">
|
||
<div class="panel-head">
|
||
<span class="panel-title">最近动态</span>
|
||
<el-select
|
||
v-model="activityFilters.module"
|
||
size="small"
|
||
clearable
|
||
placeholder="全部模块"
|
||
class="activity-filter"
|
||
aria-label="动态模块"
|
||
@change="searchActivities"
|
||
>
|
||
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="activity-body">
|
||
<!--
|
||
v-loading 只罩列表,不罩分页条:遮罩会拦截指针事件,盖住分页器后
|
||
用户点「上一页/下一页」其实点在遮罩上被吞掉,再点一次才生效,
|
||
观感就是「数据反复变化」。
|
||
-->
|
||
<div v-loading="activityLoading" class="activity-list-wrap">
|
||
<ListLoadError v-if="activityFailed" :loading="activityLoading" @retry="loadActivities" />
|
||
<div v-if="activities.length" class="activity-list">
|
||
<div class="activity-row activity-row--head">
|
||
<span>时间</span>
|
||
<span class="activity-cell--tag">模块 · 动作</span>
|
||
<span>摘要</span>
|
||
<span class="activity-cell--user">操作人</span>
|
||
</div>
|
||
<div v-for="item in activities" :key="item.id" class="activity-row">
|
||
<span class="activity-cell activity-cell--time">
|
||
{{ formatTime(item.create_time) }}
|
||
</span>
|
||
<span class="activity-cell activity-cell--tag">
|
||
<el-tag
|
||
size="small"
|
||
:type="item.success === 1 ? 'info' : 'danger'"
|
||
disable-transitions
|
||
>
|
||
{{ item.module }} · {{ item.action }}
|
||
</el-tag>
|
||
</span>
|
||
<span class="activity-cell activity-cell--summary" :title="item.summary">
|
||
{{ item.summary }}
|
||
</span>
|
||
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="!activityLoading && !activityFailed" class="list-empty list-empty--block">暂无动态</div>
|
||
</div>
|
||
|
||
<AppPagination
|
||
v-if="activityTotal > 0"
|
||
class="activity-pager"
|
||
:total="activityTotal"
|
||
:page-num="activityPageNum"
|
||
:page-size="activityPageSize"
|
||
@page-change="onActivityPageChange"
|
||
@size-change="onActivityPageSizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ⑤ 常用操作 -->
|
||
<section class="panel panel--slim" aria-label="常用操作">
|
||
<div class="panel-head">
|
||
<span class="panel-title">常用操作</span>
|
||
</div>
|
||
<div class="quick-actions">
|
||
<button v-if="user.hasPermission('business:receipts:view')" class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
|
||
<button v-if="user.hasPermission('payroll:view')" class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
||
<button v-if="user.hasPermission('settlements:view')" class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
|
||
<button v-if="user.hasPermission('reports:personal:view')" class="quick-btn" type="button" @click="go('/reports/personal-detail')">
|
||
报表统计
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.workbench { min-width: 0; }
|
||
.dashboard-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px 24px; }
|
||
.dashboard-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||
.dashboard-filter > span { font-size: 13px; color: var(--ys-ink-2); }
|
||
.overview-note { margin: 0 0 14px; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; }
|
||
.dashboard-notice { margin-bottom: 16px; }
|
||
.sync-context { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; padding: 16px 0; border-top: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line); color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||
.sync-context strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
|
||
.sync-context__state { color: var(--ys-indigo); }
|
||
.sync-context .el-button { margin-left: auto; }
|
||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||
|
||
/*
|
||
* 区块式布局(替代旧版等高 bento 网格):
|
||
* 每个区块独立成行、各自自适应高度,列表区用 flex:1 撑满卡片并内部滚动。
|
||
* 旧版 grid-auto-rows 固定 102px,把 150px 高的列表裁到只剩一行。
|
||
*/
|
||
.workbench > section {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.workbench > section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* ── ① KPI 四格 ───────────────────────────────────────────────────────── */
|
||
.kpi-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 0;
|
||
background: var(--ys-paper);
|
||
border: 1px solid var(--ys-line);
|
||
border-radius: var(--ys-radius);
|
||
}
|
||
|
||
.kpi-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
min-height: 108px;
|
||
padding: 16px 18px;
|
||
background: none;
|
||
}
|
||
.kpi-card + .kpi-card { border-left: 1px solid var(--ys-line-soft); }
|
||
|
||
.kpi-label {
|
||
font-size: 12px;
|
||
letter-spacing: 0.04em;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.kpi-value {
|
||
margin-top: 10px;
|
||
font-size: 26px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
color: var(--ys-ink);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.kpi-value small {
|
||
margin-left: 4px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.kpi-foot {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
/* ── 通用面板 ─────────────────────────────────────────────────────────── */
|
||
.grid-2 {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
padding: 16px 18px;
|
||
background: var(--ys-paper);
|
||
border: 1px solid var(--ys-line);
|
||
border-radius: var(--ys-radius);
|
||
box-shadow: var(--ys-shadow-card);
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex: none;
|
||
min-height: 22px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.panel-hint {
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.panel-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;
|
||
}
|
||
|
||
/* ── ② 趋势图 ─────────────────────────────────────────────────────────── */
|
||
.chart {
|
||
height: 190px;
|
||
margin-top: 4px;
|
||
}
|
||
.chart-empty { min-height: 190px; padding: 32px 24px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 10px; }
|
||
.chart-empty strong { font-size: 14px; font-weight: 500; color: var(--ys-ink); }
|
||
.chart-empty p { max-width: 440px; margin: 0; font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); }
|
||
|
||
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
|
||
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
|
||
.panel--list {
|
||
min-height: 0;
|
||
}
|
||
|
||
.list-block {
|
||
display: flex;
|
||
flex: none;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.list-block + .list-block {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.list-block__head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: none;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.list-block__title {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.list-block__count {
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.list-block__more {
|
||
margin-left: auto;
|
||
padding: 0;
|
||
font-size: 11px;
|
||
color: var(--ys-indigo);
|
||
cursor: pointer;
|
||
background: none;
|
||
border: none;
|
||
transition: color 150ms var(--ys-ease);
|
||
}
|
||
|
||
.list-block__more:hover {
|
||
color: var(--ys-indigo-deep);
|
||
}
|
||
|
||
.list-block__more:focus-visible {
|
||
outline: 2px solid var(--ys-indigo);
|
||
outline-offset: 2px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* 列表滚动区:撑满卡片剩余高度,超出部分内部滚动 */
|
||
.list-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
max-height: 260px;
|
||
}
|
||
|
||
/*
|
||
* 列表行:单行 grid —— 标题占主位、右元信息定宽靠右。
|
||
* 行高约 30px(旧版两行式约 47px),同高度下可见条数从 1 条升到 4~5 条。
|
||
*/
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: baseline;
|
||
gap: 12px;
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
font: inherit;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 1px dashed var(--ys-line);
|
||
transition: background-color 150ms var(--ys-ease);
|
||
}
|
||
|
||
.row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.row:hover {
|
||
background: rgba(46, 75, 168, 0.04);
|
||
}
|
||
|
||
.row:focus-visible {
|
||
outline: 2px solid var(--ys-indigo);
|
||
outline-offset: -2px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.row__main {
|
||
font-size: 13px;
|
||
color: var(--ys-ink);
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.row__meta {
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
max-width: 28ch;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 预警行:标签 | 标题 | 详情 三列并排 */
|
||
.row--warn {
|
||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr);
|
||
align-items: center;
|
||
}
|
||
.row--warn .row__meta { white-space: normal; line-height: 1.7; }
|
||
|
||
.row__tag {
|
||
display: inline-flex;
|
||
}
|
||
|
||
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
|
||
.list-empty {
|
||
display: flex;
|
||
flex: none;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
min-height: 56px;
|
||
padding: 16px 8px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
text-align: left;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
/* 面板级空态(预警/动态无数据时独占一屏高度) */
|
||
.list-empty--block {
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.list-more {
|
||
flex: none;
|
||
padding: 8px 0 0;
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* 卡底状态条:把「模块未启用」这类单行事实收进一个紧凑带里,不占列表空间 */
|
||
.status-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
flex: none;
|
||
margin-top: 12px;
|
||
padding: 8px 10px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-2);
|
||
background: rgba(28, 35, 51, 0.03);
|
||
border-radius: var(--ys-radius-sm);
|
||
}
|
||
|
||
.status-strip__label {
|
||
flex: none;
|
||
font-weight: 500;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.status-strip__value {
|
||
min-width: 0;
|
||
}
|
||
|
||
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
|
||
.panel--activity {
|
||
min-height: 0;
|
||
}
|
||
|
||
.activity-filter {
|
||
width: 140px;
|
||
}
|
||
|
||
/*
|
||
* 动态列表区:改成服务端分页后,每页条数有界(10/20/50),
|
||
* 不再靠内部滚动把内容藏起来 —— 列表整段铺开,底部接分页条。
|
||
*/
|
||
.activity-body {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 遮罩只覆盖列表:position: relative 是 v-loading 遮罩的定位基准 */
|
||
.activity-list-wrap {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.activity-list {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 分页条:右对齐铺满整行,与列表之间用一条细线分开 */
|
||
.activity-pager {
|
||
width: 100%;
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--ys-line-soft);
|
||
}
|
||
|
||
.activity-row {
|
||
display: grid;
|
||
grid-template-columns: 132px 176px minmax(0, 1fr) 92px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 7px 8px;
|
||
font-size: 12.5px;
|
||
border-bottom: 1px dashed var(--ys-line);
|
||
}
|
||
|
||
/* 表头吸顶:列表滚动时列义始终可见 */
|
||
.activity-row--head {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
color: var(--ys-ink-3);
|
||
background: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||
border-bottom: 1px solid var(--ys-line);
|
||
}
|
||
|
||
.activity-cell {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.activity-cell--time {
|
||
color: var(--ys-ink-3);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.activity-cell--summary {
|
||
color: var(--ys-ink-2);
|
||
white-space: normal;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.activity-cell--user {
|
||
color: var(--ys-ink-3);
|
||
text-align: right;
|
||
}
|
||
|
||
/* ── ⑤ 常用操作 ───────────────────────────────────────────────────────── */
|
||
.panel--slim {
|
||
padding-bottom: 18px;
|
||
}
|
||
|
||
.quick-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.quick-btn {
|
||
padding: 7px 16px;
|
||
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);
|
||
}
|
||
|
||
.quick-btn:hover {
|
||
background: #e6ecf9;
|
||
border-color: rgba(46, 75, 168, 0.35);
|
||
}
|
||
|
||
.quick-btn:focus-visible {
|
||
outline: 2px solid var(--ys-indigo);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* ── 响应式 ───────────────────────────────────────────────────────────── */
|
||
@media (max-width: 1280px) {
|
||
.kpi-row {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.kpi-card:nth-child(3) { border-left: 0; }
|
||
.kpi-card:nth-child(n+3) { border-top: 1px solid var(--ys-line-soft); }
|
||
}
|
||
|
||
@media (max-width: 1080px) {
|
||
/* 单列时每张卡独占一行,列表区高度相应放宽 */
|
||
.grid-2 {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.panel--list {
|
||
min-height: 0;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.kpi-row {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.activity-row {
|
||
grid-template-columns: 108px 140px minmax(0, 1fr);
|
||
}
|
||
|
||
.activity-cell--user {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
.row:not(.row--warn) { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||
.row__meta { max-width: none; }
|
||
.dashboard-filter { width: 100%; }
|
||
.sync-context { gap: 8px 16px; }
|
||
.sync-context strong { flex-basis: 100%; }
|
||
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||
.kpi-card { padding: 16px; }
|
||
.kpi-value { font-size: clamp(18px, 5vw, 26px); overflow-wrap: anywhere; }
|
||
.panel {
|
||
padding: 14px;
|
||
}
|
||
|
||
.activity-row {
|
||
grid-template-columns: 96px minmax(0, 1fr);
|
||
}
|
||
|
||
.activity-cell--tag {
|
||
display: none;
|
||
}
|
||
|
||
/* 窄屏放不下三列,预警详情落到第二行 */
|
||
.row--warn {
|
||
grid-template-columns: auto minmax(0, 1fr);
|
||
}
|
||
|
||
.row--warn .row__meta {
|
||
grid-column: 2;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.panel,
|
||
.kpi-card,
|
||
.row,
|
||
.list-block__more,
|
||
.quick-btn {
|
||
transition: none;
|
||
}
|
||
|
||
.panel:hover,
|
||
.kpi-card:hover {
|
||
transform: none;
|
||
}
|
||
}
|
||
</style>
|