PayrollManagementSystem/frontend/src/views/dashboard/index.vue
liuwenxin b3de35c2ec fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
2026-11-06 16:27:45 +08:00

1091 lines
35 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, 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>