|
|
@@ -0,0 +1,1061 @@
|
|
|
+<template>
|
|
|
+ <div class="dynamic-dashboard">
|
|
|
+ <header class="dynamic-dashboard__header">
|
|
|
+ <div>
|
|
|
+ <div class="dynamic-dashboard__eyebrow">{{ moduleCode }} · Dynamic Dashboard</div>
|
|
|
+ <h1>{{ page?.title || title }}</h1>
|
|
|
+ <p>{{ page?.subtitle || '由运营指标主数据和看板配置动态生成' }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="dynamic-dashboard__actions">
|
|
|
+ <el-tag type="success" effect="dark">KPI Master</el-tag>
|
|
|
+ <el-button size="small" type="primary" plain @click="goModeling">配置指标</el-button>
|
|
|
+ <el-button size="small" type="warning" plain @click="goDashboardConfig">详情看板配置</el-button>
|
|
|
+ <el-button size="small" type="success" plain @click="goSmartDiagnosis">智慧诊断</el-button>
|
|
|
+ <el-button size="small" :loading="loading" @click="reload">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <DetailQueryBar dark band-title="基础查询:日期、产品、订单号、产线" @query="onQuery" @reset="onReset">
|
|
|
+ <SmartOpsBaseQueryFields v-model="baseQuery" compact />
|
|
|
+ <slot name="extra-fields" :query="extraQuery" />
|
|
|
+ </DetailQueryBar>
|
|
|
+
|
|
|
+ <el-alert
|
|
|
+ v-if="!loading && !page"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ title="暂未读取到动态看板配置或指标数据,请先确认运营指标主数据和日值数据。"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div v-loading="loading" class="dynamic-dashboard__body">
|
|
|
+ <el-tabs v-if="useTabs" v-model="activeTab" class="dashboard-tabs" @tab-change="renderChartsSoon">
|
|
|
+ <el-tab-pane v-for="tab in pageTabs" :key="tab.metricCode" :label="tab.title" :name="tab.metricCode" />
|
|
|
+ </el-tabs>
|
|
|
+ <section v-if="useCardTabs && coreWidgets.length" class="dashboard-section dashboard-section--core-tabs">
|
|
|
+ <div class="dashboard-section__header">
|
|
|
+ <h2>核心指标</h2>
|
|
|
+ <span>{{ coreWidgets.length }} 个 L1 指标</span>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-grid dashboard-grid--core-tabs">
|
|
|
+ <article
|
|
|
+ v-for="widget in coreWidgets"
|
|
|
+ :key="widget.widgetCode"
|
|
|
+ class="dashboard-widget dashboard-widget--core-tab"
|
|
|
+ :class="[{ 'is-active': widget.tabMetricCode === activeTab }, { 'is-blink': hasBlinkMetric(widget) }]"
|
|
|
+ @click="selectCardTab(widget)"
|
|
|
+ >
|
|
|
+ <div class="dashboard-widget__header">
|
|
|
+ <div>
|
|
|
+ <h3>{{ widget.title || widget.metrics[0]?.metricName || '核心指标' }}</h3>
|
|
|
+ <p>{{ widget.metrics[0]?.department || 'L1 指标' }}</p>
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" :type="tagType(widget.metrics[0]?.statusColor)" :class="{ 'blink-dot': shouldBlinkWidget(widget) }">
|
|
|
+ {{ statusText(widget.metrics[0]?.statusColor) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary">
|
|
|
+ <div class="metric-primary__value" :class="valueClass(widget.metrics[0]?.statusColor)">
|
|
|
+ {{ formatValue(widget.metrics[0]?.currentValue) }}
|
|
|
+ <span>{{ widget.metrics[0]?.unit || '' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary__meta">
|
|
|
+ <span v-if="widget.metrics[0]?.targetValue != null">目标 {{ formatValue(widget.metrics[0]?.targetValue) }}{{ widget.metrics[0]?.unit || '' }}</span>
|
|
|
+ <span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section v-if="useCardTabs && activeL2Metrics.length" class="dashboard-section dashboard-section--l2-cards">
|
|
|
+ <div class="dashboard-section__header">
|
|
|
+ <h2>L2 指标</h2>
|
|
|
+ <span>{{ activeL2Metrics.length }} 个下层指标</span>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-grid dashboard-grid--l2-cards">
|
|
|
+ <article
|
|
|
+ v-for="metric in activeL2Metrics"
|
|
|
+ :key="metric.metricCode"
|
|
|
+ class="dashboard-widget dashboard-widget--l2-card"
|
|
|
+ :class="{ 'is-blink': metric.isBlink, 'is-active': metric.metricCode === activeL2MetricCode }"
|
|
|
+ @click="selectL2Metric(metric)"
|
|
|
+ >
|
|
|
+ <div class="dashboard-widget__header">
|
|
|
+ <div>
|
|
|
+ <h3>{{ metric.metricName }}</h3>
|
|
|
+ <p>{{ metric.metricCode }}</p>
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
|
|
|
+ {{ statusText(metric.statusColor) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary metric-primary--compact">
|
|
|
+ <div class="metric-primary__value" :class="valueClass(metric.statusColor)">
|
|
|
+ {{ formatValue(metric.currentValue) }}
|
|
|
+ <span>{{ metric.unit || '' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary__meta">
|
|
|
+ <span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
|
|
|
+ <span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="formulaText(metric)" class="metric-formula">{{ formulaText(metric) }}</div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section v-if="useCardTabs && activeL3Metrics.length" class="dashboard-section dashboard-section--drill-cards">
|
|
|
+ <div class="dashboard-section__header">
|
|
|
+ <h2>L3 关键环节</h2>
|
|
|
+ <span>{{ activeL3Metrics.length }} 个下钻指标</span>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-grid dashboard-grid--l2-cards">
|
|
|
+ <article
|
|
|
+ v-for="metric in activeL3Metrics"
|
|
|
+ :key="metric.metricCode"
|
|
|
+ class="dashboard-widget dashboard-widget--l2-card dashboard-widget--drill-card"
|
|
|
+ :class="{ 'is-blink': metric.isBlink, 'is-active': metric.metricCode === activeL3MetricCode }"
|
|
|
+ @click="selectL3Metric(metric)"
|
|
|
+ >
|
|
|
+ <div class="dashboard-widget__header">
|
|
|
+ <div>
|
|
|
+ <h3>{{ metric.metricName }}</h3>
|
|
|
+ <p>{{ metric.department || metric.metricCode }}</p>
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
|
|
|
+ {{ statusText(metric.statusColor) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary metric-primary--compact">
|
|
|
+ <div class="metric-primary__value" :class="valueClass(metric.statusColor)">
|
|
|
+ {{ formatValue(metric.currentValue) }}
|
|
|
+ <span>{{ metric.unit || '' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary__meta">
|
|
|
+ <span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
|
|
|
+ <span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section v-if="useCardTabs && activeL4Metrics.length" class="dashboard-section dashboard-section--drill-cards">
|
|
|
+ <div class="dashboard-section__header">
|
|
|
+ <h2>L4 问题触点</h2>
|
|
|
+ <span>{{ activeL4Metrics.length }} 个责任/问题节点</span>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-grid dashboard-grid--l2-cards">
|
|
|
+ <article
|
|
|
+ v-for="metric in activeL4Metrics"
|
|
|
+ :key="metric.metricCode"
|
|
|
+ class="dashboard-widget dashboard-widget--l2-card dashboard-widget--drill-card"
|
|
|
+ :class="{ 'is-blink': metric.isBlink }"
|
|
|
+ >
|
|
|
+ <div class="dashboard-widget__header">
|
|
|
+ <div>
|
|
|
+ <h3>{{ metric.metricName }}</h3>
|
|
|
+ <p>{{ metric.department || metric.metricCode }}</p>
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">
|
|
|
+ {{ statusText(metric.statusColor) }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary metric-primary--compact">
|
|
|
+ <div class="metric-primary__value" :class="valueClass(metric.statusColor)">
|
|
|
+ {{ formatValue(metric.currentValue) }}
|
|
|
+ <span>{{ metric.unit || '' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary__meta">
|
|
|
+ <span v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</span>
|
|
|
+ <span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="formulaText(metric)" class="metric-formula">{{ formulaText(metric) }}</div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section v-for="section in visibleSections" :key="section.sectionCode" class="dashboard-section">
|
|
|
+ <div class="dashboard-section__header">
|
|
|
+ <h2>{{ section.title }}</h2>
|
|
|
+ <span>{{ section.widgets.length }} 个组件</span>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-grid">
|
|
|
+ <article
|
|
|
+ v-for="widget in section.widgets"
|
|
|
+ :key="widget.widgetCode"
|
|
|
+ class="dashboard-widget"
|
|
|
+ :class="[`dashboard-widget--${widget.widgetType}`, { 'is-blink': shouldBlinkWidget(widget) }]"
|
|
|
+ :style="{ gridColumn: `span ${spanToGrid(widget.span)}` }"
|
|
|
+ >
|
|
|
+ <div class="dashboard-widget__header">
|
|
|
+ <div>
|
|
|
+ <h3>{{ widget.title || widget.metrics[0]?.metricName || '指标组件' }}</h3>
|
|
|
+ <p>{{ widgetTypeLabel(widget.widgetType) }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="dashboard-widget__tools">
|
|
|
+ <el-tag size="small" :type="tagType(widget.metrics[0]?.statusColor)" :class="{ 'blink-dot': hasBlinkMetric(widget) }">
|
|
|
+ {{ statusText(widget.metrics[0]?.statusColor) }}
|
|
|
+ </el-tag>
|
|
|
+ <el-button size="small" type="success" plain @click.stop="goMetricDiagnosis(widget)">诊断</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-if="isChartWidget(widget.widgetType)">
|
|
|
+ <div v-if="!useCardTabs && shouldShowMetricSummary(widget)" class="metric-summary">
|
|
|
+ <div v-for="metric in widget.metrics" :key="metric.metricCode" class="metric-summary__item" :class="{ 'is-blink-row': metric.isBlink }">
|
|
|
+ <div class="metric-summary__main">
|
|
|
+ <span class="metric-summary__name">{{ metric.metricName }}</span>
|
|
|
+ <span v-if="formulaText(metric)" class="metric-summary__formula">{{ formulaText(metric) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-summary__value">
|
|
|
+ <el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">{{ statusText(metric.statusColor) }}</el-tag>
|
|
|
+ <strong>{{ formatValue(metric.currentValue) }}</strong>
|
|
|
+ <span>{{ metric.unit || '' }}</span>
|
|
|
+ <small v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div :ref="(el) => setChartRef(el, widget.widgetCode)" class="dashboard-chart"></div>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="widget.widgetType === 'metric_tree' || widget.widgetType === 'metric_table'">
|
|
|
+ <div class="metric-list">
|
|
|
+ <div v-for="metric in widget.metrics" :key="metric.metricCode" class="metric-list__row" :class="{ 'is-blink-row': metric.isBlink }">
|
|
|
+ <div class="metric-list__main">
|
|
|
+ <span class="metric-list__name">{{ metric.metricName }}</span>
|
|
|
+ <span v-if="formulaText(metric)" class="metric-list__formula">{{ formulaText(metric) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-list__value">
|
|
|
+ <el-tag size="small" :type="tagType(metric.statusColor)" :class="{ 'blink-dot': metric.isBlink }">{{ statusText(metric.statusColor) }}</el-tag>
|
|
|
+ <strong>{{ formatValue(metric.currentValue) }}</strong>
|
|
|
+ <span>{{ metric.unit || '' }}</span>
|
|
|
+ <small v-if="metric.targetValue != null">目标 {{ formatValue(metric.targetValue) }}{{ metric.unit || '' }}</small>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <div class="metric-primary">
|
|
|
+ <div class="metric-primary__value" :class="valueClass(widget.metrics[0]?.statusColor)">
|
|
|
+ {{ formatValue(widget.metrics[0]?.currentValue) }}
|
|
|
+ <span>{{ widget.metrics[0]?.unit || '' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="metric-primary__meta">
|
|
|
+ <span v-if="widget.metrics[0]?.targetValue != null">目标 {{ formatValue(widget.metrics[0]?.targetValue) }}{{ widget.metrics[0]?.unit || '' }}</span>
|
|
|
+ <span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="widget.widgetType === 'progress_card' || widget.widgetType === 'traffic_light_card'" class="metric-progress">
|
|
|
+ <div class="metric-progress__bar" :style="{ width: `${achievementPercent(widget.metrics[0])}%` }"></div>
|
|
|
+ </div>
|
|
|
+ <div v-if="formulaText(widget.metrics[0])" class="metric-formula">{{ formulaText(widget.metrics[0]) }}</div>
|
|
|
+ </template>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
|
|
+import { useRouter } from 'vue-router';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import DetailQueryBar from './DetailQueryBar.vue';
|
|
|
+import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
|
|
|
+import {
|
|
|
+ fetchDynamicDashboardPage,
|
|
|
+ type DynamicDashboardMetric,
|
|
|
+ type DynamicDashboardPagePayload,
|
|
|
+ type DynamicDashboardWidget,
|
|
|
+} from '../../api/kanbanData';
|
|
|
+import {
|
|
|
+ baseQueryToApiParams,
|
|
|
+ emptySmartOpsBaseQuery,
|
|
|
+ summarizeSmartOpsBaseQuery,
|
|
|
+ type SmartOpsBaseQuery,
|
|
|
+} from '../utils/smartOpsBaseQuery';
|
|
|
+import { AIDOP_LAYOUT_SAVED } from '../utils/s4LayoutEvents';
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ moduleCode: string;
|
|
|
+ title?: string;
|
|
|
+ extraQueryInit?: Record<string, string>;
|
|
|
+ extraQueryMapper?: (query: Record<string, string>) => Record<string, string>;
|
|
|
+}>();
|
|
|
+
|
|
|
+const router = useRouter();
|
|
|
+const loading = ref(false);
|
|
|
+const page = ref<DynamicDashboardPagePayload | null>(null);
|
|
|
+const baseQuery = ref<SmartOpsBaseQuery>(emptySmartOpsBaseQuery());
|
|
|
+const extraQuery = reactive<Record<string, string>>({ ...(props.extraQueryInit || {}) });
|
|
|
+const chartRefs = new Map<string, HTMLElement>();
|
|
|
+const chartInstances = new Map<string, echarts.ECharts>();
|
|
|
+
|
|
|
+const moduleCode = computed(() => props.moduleCode.toUpperCase());
|
|
|
+const title = computed(() => props.title || `${moduleCode.value} 运营指标看板`);
|
|
|
+const activeTab = ref('');
|
|
|
+const activeL2MetricCode = ref('');
|
|
|
+const activeL3MetricCode = ref('');
|
|
|
+const pageTabs = computed(() => page.value?.tabs || []);
|
|
|
+const useTabs = computed(() => page.value?.navigationMode === 'tabs_by_l1' && pageTabs.value.length > 0);
|
|
|
+const useCardTabs = computed(() => page.value?.navigationMode === 'l1_cards' && pageTabs.value.length > 0);
|
|
|
+const coreWidgets = computed(() =>
|
|
|
+ (page.value?.sections || [])
|
|
|
+ .find((section) => section.sectionCode === 'overview')
|
|
|
+ ?.widgets?.filter((widget) => widget.metricLevel === 1) || []
|
|
|
+);
|
|
|
+const visibleSections = computed(() =>
|
|
|
+ (page.value?.sections || [])
|
|
|
+ .filter((section) => !(useCardTabs.value && section.sectionCode === 'overview'))
|
|
|
+ .map((section) => ({
|
|
|
+ ...section,
|
|
|
+ widgets: (section.widgets || []).filter((widget) => {
|
|
|
+ if (!useTabs.value && !useCardTabs.value) return true;
|
|
|
+ return !widget.tabMetricCode || widget.tabMetricCode === activeTab.value;
|
|
|
+ }),
|
|
|
+ }))
|
|
|
+ .filter((x) => x.widgets?.length)
|
|
|
+);
|
|
|
+const activeL2Metrics = computed(() => {
|
|
|
+ if (!useCardTabs.value) return [];
|
|
|
+ const map = new Map<string, DynamicDashboardMetric>();
|
|
|
+ const parentId = metricIdByCode(activeTab.value);
|
|
|
+ for (const metric of page.value?.metrics || []) {
|
|
|
+ if (metric.metricLevel === 2 && sameId(metric.parentId, parentId)) map.set(metric.metricCode, metric);
|
|
|
+ }
|
|
|
+ for (const section of visibleSections.value) {
|
|
|
+ for (const widget of section.widgets || []) {
|
|
|
+ for (const metric of widget.metrics || []) {
|
|
|
+ if (metric.metricLevel === 2 && !map.has(metric.metricCode)) map.set(metric.metricCode, metric);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Array.from(map.values());
|
|
|
+});
|
|
|
+const activeL3Metrics = computed(() => childMetrics(activeL2MetricCode.value, 3));
|
|
|
+const activeL4Metrics = computed(() => childMetrics(activeL3MetricCode.value, 4));
|
|
|
+
|
|
|
+function childMetrics(parentCode: string, level: number) {
|
|
|
+ if (!parentCode) return [];
|
|
|
+ const parentId = metricIdByCode(parentCode);
|
|
|
+ return (page.value?.metrics || [])
|
|
|
+ .filter((metric) => metric.metricLevel === level && sameId(metric.parentId, parentId))
|
|
|
+ .sort((a, b) => a.metricCode.localeCompare(b.metricCode));
|
|
|
+}
|
|
|
+
|
|
|
+function metricIdByCode(metricCode: string) {
|
|
|
+ const metric = (page.value?.metrics || []).find((x) => x.metricCode === metricCode);
|
|
|
+ return metric?.id ?? null;
|
|
|
+}
|
|
|
+
|
|
|
+function sameId(a: unknown, b: unknown) {
|
|
|
+ return a != null && b != null && String(a) === String(b);
|
|
|
+}
|
|
|
+
|
|
|
+function buildExtraParams() {
|
|
|
+ const mapper = props.extraQueryMapper || ((q: Record<string, string>) => q);
|
|
|
+ return { ...baseQueryToApiParams(baseQuery.value), ...mapper(extraQuery) };
|
|
|
+}
|
|
|
+
|
|
|
+async function reload() {
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ disposeCharts();
|
|
|
+ page.value = await fetchDynamicDashboardPage(moduleCode.value, 1, buildExtraParams());
|
|
|
+ if (page.value?.defaultTabMetricCode && !activeTab.value) activeTab.value = page.value.defaultTabMetricCode;
|
|
|
+ if (!activeTab.value && page.value?.tabs?.length) activeTab.value = page.value.tabs[0].metricCode;
|
|
|
+ syncDrillSelection();
|
|
|
+ await nextTick();
|
|
|
+ renderCharts();
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onQuery() {
|
|
|
+ reload();
|
|
|
+ ElMessage.success(`已应用筛选(${summarizeSmartOpsBaseQuery(baseQuery.value)})`);
|
|
|
+}
|
|
|
+
|
|
|
+function onReset() {
|
|
|
+ baseQuery.value = emptySmartOpsBaseQuery();
|
|
|
+ for (const key of Object.keys(extraQuery)) extraQuery[key] = props.extraQueryInit?.[key] ?? '';
|
|
|
+ reload();
|
|
|
+ ElMessage.info('已重置');
|
|
|
+}
|
|
|
+
|
|
|
+function goModeling() {
|
|
|
+ router.push({ path: '/aidop/smart-ops/modeling', query: { module: moduleCode.value } });
|
|
|
+}
|
|
|
+
|
|
|
+function goDashboardConfig() {
|
|
|
+ router.push({ path: '/aidop/smart-ops/dashboard-config', query: { module: moduleCode.value } });
|
|
|
+}
|
|
|
+
|
|
|
+function goSmartDiagnosis() {
|
|
|
+ router.push({
|
|
|
+ path: '/aidop/smart-diagnosis',
|
|
|
+ query: {
|
|
|
+ module: moduleCode.value,
|
|
|
+ ...buildExtraParams(),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function goMetricDiagnosis(widget: DynamicDashboardWidget) {
|
|
|
+ const metricCode = widget.tabMetricCode || widget.metrics[0]?.metricCode;
|
|
|
+ router.push({
|
|
|
+ path: '/aidop/smart-diagnosis',
|
|
|
+ query: {
|
|
|
+ module: moduleCode.value,
|
|
|
+ metricCode: metricCode || '',
|
|
|
+ ...buildExtraParams(),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function selectCardTab(widget: DynamicDashboardWidget) {
|
|
|
+ if (!widget.tabMetricCode) return;
|
|
|
+ disposeCharts();
|
|
|
+ activeTab.value = widget.tabMetricCode;
|
|
|
+ activeL2MetricCode.value = '';
|
|
|
+ activeL3MetricCode.value = '';
|
|
|
+ syncDrillSelection();
|
|
|
+ renderChartsSoon();
|
|
|
+}
|
|
|
+
|
|
|
+function selectL2Metric(metric: DynamicDashboardMetric) {
|
|
|
+ activeL2MetricCode.value = metric.metricCode;
|
|
|
+ activeL3MetricCode.value = '';
|
|
|
+ syncDrillSelection();
|
|
|
+}
|
|
|
+
|
|
|
+function selectL3Metric(metric: DynamicDashboardMetric) {
|
|
|
+ activeL3MetricCode.value = metric.metricCode;
|
|
|
+}
|
|
|
+
|
|
|
+function syncDrillSelection() {
|
|
|
+ if (!activeL2Metrics.value.some((metric) => metric.metricCode === activeL2MetricCode.value)) {
|
|
|
+ activeL2MetricCode.value = mostSevereMetric(activeL2Metrics.value)?.metricCode || '';
|
|
|
+ }
|
|
|
+ if (!activeL3Metrics.value.some((metric) => metric.metricCode === activeL3MetricCode.value)) {
|
|
|
+ activeL3MetricCode.value = mostSevereMetric(activeL3Metrics.value)?.metricCode || '';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function mostSevereMetric(metrics: DynamicDashboardMetric[]) {
|
|
|
+ return [...metrics].sort((a, b) => statusRank(b.statusColor) - statusRank(a.statusColor) || a.metricCode.localeCompare(b.metricCode))[0];
|
|
|
+}
|
|
|
+
|
|
|
+function statusRank(status?: string | null) {
|
|
|
+ const key = String(status || '').toLowerCase();
|
|
|
+ if (key === 'red') return 3;
|
|
|
+ if (key === 'yellow') return 2;
|
|
|
+ if (key === 'green') return 1;
|
|
|
+ return 0;
|
|
|
+}
|
|
|
+
|
|
|
+function spanToGrid(span?: number) {
|
|
|
+ const s = Number(span || 6);
|
|
|
+ if (s >= 24) return 12;
|
|
|
+ if (s >= 12) return 6;
|
|
|
+ return 3;
|
|
|
+}
|
|
|
+
|
|
|
+function formatValue(value?: number | null) {
|
|
|
+ if (value == null || Number.isNaN(Number(value))) return '—';
|
|
|
+ const n = Number(value);
|
|
|
+ return Math.abs(n - Math.round(n)) < 1e-9 ? String(Math.round(n)) : String(Math.round(n * 100) / 100);
|
|
|
+}
|
|
|
+
|
|
|
+function tagType(status?: string | null) {
|
|
|
+ const s = String(status || '').toLowerCase();
|
|
|
+ if (s === 'green') return 'success';
|
|
|
+ if (s === 'yellow') return 'warning';
|
|
|
+ if (s === 'red') return 'danger';
|
|
|
+ return 'info';
|
|
|
+}
|
|
|
+
|
|
|
+function statusText(status?: string | null) {
|
|
|
+ const s = String(status || '').toLowerCase();
|
|
|
+ if (s === 'green') return '达成';
|
|
|
+ if (s === 'yellow') return '风险';
|
|
|
+ if (s === 'red') return '未达';
|
|
|
+ return '—';
|
|
|
+}
|
|
|
+
|
|
|
+function valueClass(status?: string | null) {
|
|
|
+ return `is-${String(status || 'none').toLowerCase()}`;
|
|
|
+}
|
|
|
+
|
|
|
+function widgetTypeLabel(type?: string) {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ metric_card: '指标卡',
|
|
|
+ traffic_light_card: '红绿灯卡',
|
|
|
+ progress_card: '进度指标',
|
|
|
+ bar_compare: '目标/实际条形图',
|
|
|
+ funnel_chart: '流程漏斗',
|
|
|
+ gauge: '仪表盘指标',
|
|
|
+ trend_line: '趋势图',
|
|
|
+ line_trend: '趋势折线',
|
|
|
+ heatmap: '负荷热力图',
|
|
|
+ threshold_line: '阈值折线',
|
|
|
+ area_chart: '面积趋势',
|
|
|
+ metric_tree: '分组指标',
|
|
|
+ metric_table: '指标表格',
|
|
|
+ };
|
|
|
+ return map[String(type || '')] || '动态组件';
|
|
|
+}
|
|
|
+
|
|
|
+function isChartWidget(type?: string) {
|
|
|
+ return ['trend_line', 'line_trend', 'bar_compare', 'funnel_chart', 'gauge', 'heatmap', 'threshold_line', 'area_chart'].includes(String(type || ''));
|
|
|
+}
|
|
|
+
|
|
|
+function formulaText(metric?: DynamicDashboardMetric) {
|
|
|
+ return metric?.formulaPreview || metric?.formulaExpr || metric?.formula || '';
|
|
|
+}
|
|
|
+
|
|
|
+function achievementPercent(metric?: DynamicDashboardMetric) {
|
|
|
+ const current = Number(metric?.currentValue);
|
|
|
+ const target = Number(metric?.targetValue);
|
|
|
+ if (!Number.isFinite(current) || !Number.isFinite(target) || target === 0) return 0;
|
|
|
+ return Math.max(0, Math.min(100, Math.round((current / target) * 100)));
|
|
|
+}
|
|
|
+
|
|
|
+function hasBlinkMetric(widget: DynamicDashboardWidget) {
|
|
|
+ return (widget.metrics || []).some((metric) => metric.isBlink);
|
|
|
+}
|
|
|
+
|
|
|
+function shouldBlinkWidget(widget: DynamicDashboardWidget) {
|
|
|
+ return widget.widgetType !== 'bar_compare' && hasBlinkMetric(widget);
|
|
|
+}
|
|
|
+
|
|
|
+function shouldShowMetricSummary(widget: DynamicDashboardWidget) {
|
|
|
+ return widget.metricLevel > 1 || (widget.metrics || []).length > 1;
|
|
|
+}
|
|
|
+
|
|
|
+function isAlertMetric(metric?: DynamicDashboardMetric) {
|
|
|
+ return Boolean(metric?.isBlink) || String(metric?.statusColor || '').toLowerCase() === 'red';
|
|
|
+}
|
|
|
+
|
|
|
+function setChartRef(el: Element | null, code: string) {
|
|
|
+ if (el instanceof HTMLElement) chartRefs.set(code, el);
|
|
|
+ else {
|
|
|
+ chartRefs.delete(code);
|
|
|
+ disposeChart(code);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderCharts() {
|
|
|
+ const chartWidgets = visibleSections.value.flatMap((section) => section.widgets).filter((w) => isChartWidget(w.widgetType));
|
|
|
+ for (const widget of chartWidgets) renderChartWidget(widget);
|
|
|
+}
|
|
|
+
|
|
|
+function renderChartsSoon() {
|
|
|
+ nextTick(() => window.requestAnimationFrame(renderCharts));
|
|
|
+}
|
|
|
+
|
|
|
+function renderChartWidget(widget: DynamicDashboardWidget) {
|
|
|
+ const el = chartRefs.get(widget.widgetCode);
|
|
|
+ if (!el) return;
|
|
|
+ if (!el.isConnected) {
|
|
|
+ chartRefs.delete(widget.widgetCode);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let inst = chartInstances.get(widget.widgetCode);
|
|
|
+ if (inst && inst.getDom() !== el) {
|
|
|
+ inst.dispose();
|
|
|
+ chartInstances.delete(widget.widgetCode);
|
|
|
+ inst = undefined;
|
|
|
+ }
|
|
|
+ if (!inst) inst = echarts.init(el);
|
|
|
+ chartInstances.set(widget.widgetCode, inst);
|
|
|
+ const type = String(widget.widgetType || '');
|
|
|
+ if (type === 'gauge') {
|
|
|
+ const metric = widget.metrics[0];
|
|
|
+ inst.setOption(buildGaugeOption(metric), true);
|
|
|
+ window.requestAnimationFrame(() => inst.resize());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (type === 'bar_compare' || type === 'heatmap' || type === 'funnel_chart') {
|
|
|
+ inst.setOption(buildCompareOption(widget), true);
|
|
|
+ window.requestAnimationFrame(() => inst.resize());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ inst.setOption(buildLineOption(widget), true);
|
|
|
+ window.requestAnimationFrame(() => inst.resize());
|
|
|
+}
|
|
|
+
|
|
|
+function disposeChart(code: string) {
|
|
|
+ const inst = chartInstances.get(code);
|
|
|
+ if (!inst) return;
|
|
|
+ inst.dispose();
|
|
|
+ chartInstances.delete(code);
|
|
|
+}
|
|
|
+
|
|
|
+function disposeCharts() {
|
|
|
+ chartInstances.forEach((chart) => chart.dispose());
|
|
|
+ chartInstances.clear();
|
|
|
+ chartRefs.clear();
|
|
|
+}
|
|
|
+
|
|
|
+function buildLineOption(widget: DynamicDashboardWidget) {
|
|
|
+ const labels = Array.from(new Set(widget.metrics.flatMap((metric) => metric.trend.map((point) => point.date)))).sort();
|
|
|
+ const series = widget.metrics.map((metric) => {
|
|
|
+ const byDate = new Map(metric.trend.map((point) => [point.date, point.value]));
|
|
|
+ return {
|
|
|
+ name: metric.metricName,
|
|
|
+ type: widget.widgetType === 'area_chart' ? 'line' : 'line',
|
|
|
+ smooth: true,
|
|
|
+ areaStyle: widget.widgetType === 'area_chart' ? {} : undefined,
|
|
|
+ markLine: widget.widgetType === 'threshold_line' && metric.targetValue != null ? { data: [{ yAxis: metric.targetValue, name: '目标' }] } : undefined,
|
|
|
+ label: { show: labels.length <= 14, color: '#cbd5e1', formatter: (p: any) => formatValue(p.value) },
|
|
|
+ data: labels.map((date) => byDate.get(date) ?? null),
|
|
|
+ };
|
|
|
+ });
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: { top: 0, textStyle: { color: '#94a3b8' } },
|
|
|
+ grid: { left: 35, right: 20, bottom: 28, top: 42 },
|
|
|
+ xAxis: { type: 'category', data: labels, axisLabel: { color: '#94a3b8' } },
|
|
|
+ yAxis: { type: 'value', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: '#263244' } } },
|
|
|
+ series,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function buildGaugeOption(metric?: DynamicDashboardMetric) {
|
|
|
+ const percent = achievementPercent(metric);
|
|
|
+ return {
|
|
|
+ series: [{
|
|
|
+ type: 'gauge',
|
|
|
+ min: 0,
|
|
|
+ max: 120,
|
|
|
+ progress: { show: true, width: 12 },
|
|
|
+ axisLine: { lineStyle: { width: 12 } },
|
|
|
+ detail: { valueAnimation: true, formatter: `${percent}%`, color: '#e2e8f0' },
|
|
|
+ data: [{ value: percent, name: metric?.metricName || '达成率' }],
|
|
|
+ }],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function buildCompareOption(widget: DynamicDashboardWidget) {
|
|
|
+ const metrics = widget.metrics || [];
|
|
|
+ if (widget.widgetType === 'funnel_chart') {
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'item' },
|
|
|
+ series: [{
|
|
|
+ type: 'funnel',
|
|
|
+ left: 20,
|
|
|
+ right: 20,
|
|
|
+ top: 20,
|
|
|
+ bottom: 10,
|
|
|
+ label: { show: true, formatter: '{b}: {c}' },
|
|
|
+ data: metrics.map((metric) => ({ name: metric.metricName, value: Number(metric.currentValue ?? 0) })),
|
|
|
+ }],
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const actualData = metrics.map((metric) => {
|
|
|
+ const alert = isAlertMetric(metric);
|
|
|
+ return {
|
|
|
+ value: metric.currentValue ?? 0,
|
|
|
+ itemStyle: {
|
|
|
+ color: alert ? '#fb7185' : '#60a5fa',
|
|
|
+ shadowBlur: alert ? 16 : 0,
|
|
|
+ shadowColor: alert ? 'rgba(251, 113, 133, 0.75)' : 'transparent',
|
|
|
+ },
|
|
|
+ label: { color: alert ? '#fecdd3' : '#dbeafe' },
|
|
|
+ };
|
|
|
+ });
|
|
|
+ return {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: { top: 0, textStyle: { color: '#94a3b8' } },
|
|
|
+ grid: { left: 55, right: 20, bottom: 42, top: 50 },
|
|
|
+ xAxis: { type: 'category', data: metrics.map((metric) => metric.metricName), axisLabel: { color: '#94a3b8', interval: 0, rotate: 20 } },
|
|
|
+ yAxis: { type: 'value', axisLabel: { color: '#94a3b8' }, splitLine: { lineStyle: { color: '#263244' } } },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: '目标值',
|
|
|
+ type: 'bar',
|
|
|
+ data: metrics.map((metric) => metric.targetValue ?? 0),
|
|
|
+ itemStyle: { color: '#64748b' },
|
|
|
+ label: { show: true, position: 'top', color: '#cbd5e1', formatter: (p: any) => formatValue(p.value) },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: '实际达成',
|
|
|
+ type: 'bar',
|
|
|
+ data: actualData,
|
|
|
+ label: { show: true, position: 'top', color: '#dbeafe', formatter: (p: any) => formatValue(p.value) },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function onResize() {
|
|
|
+ chartInstances.forEach((chart) => chart.resize());
|
|
|
+}
|
|
|
+
|
|
|
+function onLayoutSaved(event: Event) {
|
|
|
+ const detail = (event as CustomEvent<{ moduleCode?: string }>).detail;
|
|
|
+ if (!detail?.moduleCode || detail.moduleCode.toUpperCase() === moduleCode.value) reload();
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ reload();
|
|
|
+ window.addEventListener('resize', onResize);
|
|
|
+ window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', onResize);
|
|
|
+ window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved as EventListener);
|
|
|
+ disposeCharts();
|
|
|
+});
|
|
|
+
|
|
|
+watch(page, () => renderChartsSoon());
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.dynamic-dashboard {
|
|
|
+ min-height: 100%;
|
|
|
+ background: #0b0f19;
|
|
|
+ color: #e2e8f0;
|
|
|
+ padding: 16px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 16px;
|
|
|
+ align-items: center;
|
|
|
+ padding: 18px 20px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+ border: 1px solid #1e293b;
|
|
|
+ border-radius: 14px;
|
|
|
+ background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.7));
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__eyebrow {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #60a5fa;
|
|
|
+ font-weight: 700;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__header h1 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 22px;
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__header p {
|
|
|
+ margin: 6px 0 0;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.dynamic-dashboard__body {
|
|
|
+ min-height: 360px;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-tabs {
|
|
|
+ margin-top: 12px;
|
|
|
+ --el-text-color-primary: #e2e8f0;
|
|
|
+ --el-color-primary: #60a5fa;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-section {
|
|
|
+ margin-top: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-section__header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-section__header h2 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ color: #f8fafc;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(12, minmax(0, 1fr));
|
|
|
+ gap: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-grid--core-tabs {
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-grid--l2-cards {
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget {
|
|
|
+ min-height: 160px;
|
|
|
+ border: 1px solid #1e293b;
|
|
|
+ border-radius: 14px;
|
|
|
+ padding: 16px;
|
|
|
+ background: rgba(15, 23, 42, 0.78);
|
|
|
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--core-tab {
|
|
|
+ min-height: 150px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--core-tab:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ border-color: rgba(96, 165, 250, 0.65);
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--core-tab.is-active {
|
|
|
+ border-color: #60a5fa;
|
|
|
+ box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.55), 0 18px 40px rgba(37, 99, 235, 0.22);
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--l2-card {
|
|
|
+ min-height: 132px;
|
|
|
+ background: rgba(15, 23, 42, 0.68);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--l2-card:hover,
|
|
|
+.dashboard-widget--l2-card.is-active {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ border-color: rgba(45, 212, 191, 0.68);
|
|
|
+ box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35), 0 16px 36px rgba(13, 148, 136, 0.16);
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--drill-card {
|
|
|
+ min-height: 124px;
|
|
|
+ background: rgba(15, 23, 42, 0.56);
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget.is-blink,
|
|
|
+.is-blink-row {
|
|
|
+ animation: dashboard-red-pulse 1.2s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.blink-dot {
|
|
|
+ animation: dashboard-red-pulse 1.2s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes dashboard-red-pulse {
|
|
|
+ 0%, 100% {
|
|
|
+ box-shadow: 0 0 0 rgba(248, 113, 113, 0);
|
|
|
+ }
|
|
|
+ 50% {
|
|
|
+ box-shadow: 0 0 18px rgba(248, 113, 113, 0.85);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget--trend_line {
|
|
|
+ min-height: 330px;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget__header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget__tools {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget__header h3 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 15px;
|
|
|
+ color: #f8fafc;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-widget__header p {
|
|
|
+ margin: 4px 0 0;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__value {
|
|
|
+ font-size: 38px;
|
|
|
+ font-weight: 800;
|
|
|
+ color: #e2e8f0;
|
|
|
+ letter-spacing: -1px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__value span {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #94a3b8;
|
|
|
+ margin-left: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary--compact .metric-primary__value {
|
|
|
+ font-size: 28px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__value.is-green {
|
|
|
+ color: #34d399;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__value.is-yellow {
|
|
|
+ color: #fbbf24;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__value.is-red {
|
|
|
+ color: #fb7185;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-primary__meta {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+ font-size: 12px;
|
|
|
+ margin-top: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-progress {
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #1e293b;
|
|
|
+ overflow: hidden;
|
|
|
+ margin-top: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-progress__bar {
|
|
|
+ height: 100%;
|
|
|
+ background: linear-gradient(90deg, #60a5fa, #34d399);
|
|
|
+ border-radius: inherit;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-formula,
|
|
|
+.metric-list__formula,
|
|
|
+.metric-summary__formula {
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ margin-top: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 14px;
|
|
|
+ padding: 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(30, 41, 59, 0.62);
|
|
|
+ border: 1px solid rgba(51, 65, 85, 0.9);
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__main {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__name {
|
|
|
+ display: block;
|
|
|
+ color: #e2e8f0;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__value {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ color: #94a3b8;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__value strong {
|
|
|
+ color: #f8fafc;
|
|
|
+ font-size: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-list__value small {
|
|
|
+ color: #64748b;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(30, 41, 59, 0.58);
|
|
|
+ border: 1px solid rgba(51, 65, 85, 0.86);
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__main {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__name {
|
|
|
+ display: block;
|
|
|
+ color: #e2e8f0;
|
|
|
+ font-weight: 600;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ max-width: 220px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__value {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ color: #94a3b8;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__value strong {
|
|
|
+ color: #f8fafc;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.metric-summary__value small {
|
|
|
+ color: #64748b;
|
|
|
+}
|
|
|
+
|
|
|
+.dashboard-chart {
|
|
|
+ height: 260px;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 1200px) {
|
|
|
+ .dashboard-widget {
|
|
|
+ grid-column: span 12 !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|