|
|
@@ -0,0 +1,815 @@
|
|
|
+<script setup lang="ts" name="aidopS8Dashboard">
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
|
|
+import { useRouter } from 'vue-router';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
|
|
|
+import S8DashboardConfigDrawer from './S8DashboardConfigDrawer.vue';
|
|
|
+import {
|
|
|
+ s8DashboardApi,
|
|
|
+ type S8DeptBacklogItem,
|
|
|
+ type S8Distributions,
|
|
|
+ type S8OverviewData,
|
|
|
+} from '../api/s8DashboardApi';
|
|
|
+import service from '/@/utils/request';
|
|
|
+import { Local } from '/@/utils/storage';
|
|
|
+
|
|
|
+// ─── 路由 ────────────────────────────────────────────────────
|
|
|
+const router = useRouter();
|
|
|
+
|
|
|
+type PanelKey = 'object' | 'process' | 'occurrence' | 'responsible';
|
|
|
+type TrendDays = 7 | 14 | 30;
|
|
|
+
|
|
|
+interface PanelFilterState {
|
|
|
+ severity: string;
|
|
|
+ dateStart: Date | null;
|
|
|
+ dateEnd: Date | null;
|
|
|
+ extra: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface DashboardPreferences {
|
|
|
+ defaultPanel: PanelKey;
|
|
|
+ trendDays: TrendDays;
|
|
|
+ sections: {
|
|
|
+ showRefreshBar: boolean;
|
|
|
+ showFilterBar: boolean;
|
|
|
+ showKpiSection: boolean;
|
|
|
+ showTrendChart: boolean;
|
|
|
+ showDetailTable: boolean;
|
|
|
+ };
|
|
|
+ visibleKpiKeys: string[];
|
|
|
+}
|
|
|
+
|
|
|
+const DASHBOARD_PREFERENCE_KEY = 'aidop-s8-dashboard-preferences';
|
|
|
+
|
|
|
+// ─── 面板 / KPI 元信息 ───────────────────────────────────────
|
|
|
+const panels = [
|
|
|
+ { key: 'object' as PanelKey, label: '对象总览' },
|
|
|
+ { key: 'process' as PanelKey, label: '流程环节' },
|
|
|
+ { key: 'occurrence' as PanelKey, label: '发生部门' },
|
|
|
+ { key: 'responsible' as PanelKey, label: '处理部门' },
|
|
|
+];
|
|
|
+
|
|
|
+const kpiCards = [
|
|
|
+ { key: 'total', label: '总数', suffix: '', color: '#6366f1' },
|
|
|
+ { key: 'pending', label: '待处理', suffix: '', color: '#f59e0b' },
|
|
|
+ { key: 'inProgress', label: '处理中', suffix: '', color: '#3b82f6' },
|
|
|
+ { key: 'timeout', label: '超时', suffix: '', color: '#ef4444' },
|
|
|
+ { key: 'closed', label: '已闭环', suffix: '', color: '#10b981' },
|
|
|
+ { key: 'todayNew', label: '今日新增', suffix: '', color: '#8b5cf6' },
|
|
|
+ { key: 'closureRate', label: '闭环率', suffix: '%', color: '#10b981' },
|
|
|
+ { key: 'avgCycleHours', label: '均处理周期', suffix: 'h', color: '#64748b' },
|
|
|
+];
|
|
|
+
|
|
|
+const allKpiKeys = kpiCards.map((card) => card.key);
|
|
|
+
|
|
|
+function createDefaultDateRange() {
|
|
|
+ const end = new Date();
|
|
|
+ end.setHours(0, 0, 0, 0);
|
|
|
+ const start = new Date(end);
|
|
|
+ start.setDate(end.getDate() - 7);
|
|
|
+ return { start, end };
|
|
|
+}
|
|
|
+
|
|
|
+function createDefaultPanelFilter(): PanelFilterState {
|
|
|
+ const { start, end } = createDefaultDateRange();
|
|
|
+ return {
|
|
|
+ severity: '',
|
|
|
+ dateStart: start,
|
|
|
+ dateEnd: end,
|
|
|
+ extra: '',
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function createDefaultPanelFilters(): Record<PanelKey, PanelFilterState> {
|
|
|
+ return {
|
|
|
+ object: createDefaultPanelFilter(),
|
|
|
+ process: createDefaultPanelFilter(),
|
|
|
+ occurrence: createDefaultPanelFilter(),
|
|
|
+ responsible: createDefaultPanelFilter(),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const DEFAULT_PREFERENCES: DashboardPreferences = {
|
|
|
+ defaultPanel: 'object',
|
|
|
+ trendDays: 14,
|
|
|
+ sections: {
|
|
|
+ showRefreshBar: true,
|
|
|
+ showFilterBar: true,
|
|
|
+ showKpiSection: true,
|
|
|
+ showTrendChart: true,
|
|
|
+ showDetailTable: true,
|
|
|
+ },
|
|
|
+ visibleKpiKeys: [...allKpiKeys],
|
|
|
+};
|
|
|
+
|
|
|
+function clonePreferences(source: DashboardPreferences): DashboardPreferences {
|
|
|
+ return {
|
|
|
+ defaultPanel: source.defaultPanel,
|
|
|
+ trendDays: source.trendDays,
|
|
|
+ sections: { ...source.sections },
|
|
|
+ visibleKpiKeys: [...source.visibleKpiKeys],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function normalizePreferences(raw: unknown): DashboardPreferences {
|
|
|
+ const source = (raw && typeof raw === 'object' ? raw : {}) as Partial<DashboardPreferences>;
|
|
|
+ const panelKeys = new Set(panels.map((item) => item.key));
|
|
|
+ const trendDays = source.trendDays;
|
|
|
+ const visibleKpiKeys = Array.isArray(source.visibleKpiKeys)
|
|
|
+ ? source.visibleKpiKeys.filter((key): key is string => typeof key === 'string' && allKpiKeys.includes(key))
|
|
|
+ : [];
|
|
|
+
|
|
|
+ return {
|
|
|
+ defaultPanel: panelKeys.has(source.defaultPanel as PanelKey) ? (source.defaultPanel as PanelKey) : DEFAULT_PREFERENCES.defaultPanel,
|
|
|
+ trendDays: trendDays === 7 || trendDays === 14 || trendDays === 30 ? trendDays : DEFAULT_PREFERENCES.trendDays,
|
|
|
+ sections: {
|
|
|
+ showRefreshBar: source.sections?.showRefreshBar ?? DEFAULT_PREFERENCES.sections.showRefreshBar,
|
|
|
+ showFilterBar: source.sections?.showFilterBar ?? DEFAULT_PREFERENCES.sections.showFilterBar,
|
|
|
+ showKpiSection: source.sections?.showKpiSection ?? DEFAULT_PREFERENCES.sections.showKpiSection,
|
|
|
+ showTrendChart: source.sections?.showTrendChart ?? DEFAULT_PREFERENCES.sections.showTrendChart,
|
|
|
+ showDetailTable: source.sections?.showDetailTable ?? DEFAULT_PREFERENCES.sections.showDetailTable,
|
|
|
+ },
|
|
|
+ visibleKpiKeys: visibleKpiKeys.length ? visibleKpiKeys : [...DEFAULT_PREFERENCES.visibleKpiKeys],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function savePreferences(config: DashboardPreferences) {
|
|
|
+ Local.set(DASHBOARD_PREFERENCE_KEY, config);
|
|
|
+}
|
|
|
+
|
|
|
+const preferences = reactive<DashboardPreferences>(normalizePreferences(Local.get(DASHBOARD_PREFERENCE_KEY)));
|
|
|
+
|
|
|
+// ─── 状态 ────────────────────────────────────────────────────
|
|
|
+const loading = ref(false);
|
|
|
+const lastRefreshedAt = ref('');
|
|
|
+const configDrawerVisible = ref(false);
|
|
|
+
|
|
|
+// 筛选条件
|
|
|
+const panelFilters = reactive<Record<PanelKey, PanelFilterState>>(createDefaultPanelFilters());
|
|
|
+const activePanel = ref<PanelKey>(preferences.defaultPanel);
|
|
|
+const currentPanelFilter = computed(() => panelFilters[activePanel.value]);
|
|
|
+
|
|
|
+// 数据
|
|
|
+const overview = reactive<S8OverviewData>({
|
|
|
+ total: 0, pending: 0, inProgress: 0, timeout: 0, closed: 0,
|
|
|
+ todayNew: 0, critical: 0, closureRate: 0, avgCycleHours: 0,
|
|
|
+});
|
|
|
+const distributions = ref<S8Distributions | null>(null);
|
|
|
+const deptBacklog = ref<S8DeptBacklogItem[]>([]);
|
|
|
+const dimTrendsData = ref<{ dates: string[]; series: { name: string; data: number[] }[] }>({ dates: [], series: [] });
|
|
|
+
|
|
|
+// 明细表
|
|
|
+const detailList = ref<any[]>([]);
|
|
|
+const detailTotal = ref(0);
|
|
|
+const detailPage = ref(1);
|
|
|
+const detailPageSize = ref(20);
|
|
|
+const chartDrillValue = ref('');
|
|
|
+
|
|
|
+// ─── ECharts ─────────────────────────────────────────────────
|
|
|
+const mainChartRef = ref<HTMLElement | null>(null);
|
|
|
+let mainChart: echarts.ECharts | null = null;
|
|
|
+const mainChartEmpty = ref(false);
|
|
|
+
|
|
|
+const trendChartRef = ref<HTMLElement | null>(null);
|
|
|
+let trendChart: echarts.ECharts | null = null;
|
|
|
+
|
|
|
+// ─── 工具函数 ─────────────────────────────────────────────────
|
|
|
+function severityTagType(s: string) {
|
|
|
+ return ({ CRITICAL: 'danger', HIGH: 'warning', MEDIUM: '', LOW: 'success' } as any)[s] ?? 'info';
|
|
|
+}
|
|
|
+function statusTagType(s: string) {
|
|
|
+ return ({ NEW: 'info', ASSIGNED: '', IN_PROGRESS: 'warning', RESOLVED: 'success', CLOSED: 'success' } as any)[s] ?? 'info';
|
|
|
+}
|
|
|
+function fmtDate(v: string | null | undefined) {
|
|
|
+ return v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—';
|
|
|
+}
|
|
|
+
|
|
|
+// ─── 面板专属筛选项 ──────────────────────────────────────────
|
|
|
+const processNodeOptions = computed(() =>
|
|
|
+ (distributions.value?.byProcess ?? []).map(p => ({ label: p.nodeName, value: p.key }))
|
|
|
+);
|
|
|
+const occurrenceDeptOptions = computed(() =>
|
|
|
+ (distributions.value?.byOccurrenceDept ?? []).map(d => ({ label: d.deptName, value: String(d.key) }))
|
|
|
+);
|
|
|
+const responsibleDeptOptions = computed(() =>
|
|
|
+ (distributions.value?.byDept ?? []).map(d => ({ label: d.deptName, value: String(d.key) }))
|
|
|
+);
|
|
|
+
|
|
|
+// ─── 图表渲染 ─────────────────────────────────────────────────
|
|
|
+async function renderMainChart() {
|
|
|
+ await nextTick();
|
|
|
+ if (!mainChartRef.value) return;
|
|
|
+
|
|
|
+ if (!mainChart) {
|
|
|
+ mainChart = echarts.init(mainChartRef.value, undefined, { renderer: 'canvas' });
|
|
|
+ }
|
|
|
+
|
|
|
+ const panel = activePanel.value;
|
|
|
+ let categories: string[] = [];
|
|
|
+ let values: number[] = [];
|
|
|
+ let barColor = '#6366f1';
|
|
|
+ let title = '';
|
|
|
+
|
|
|
+ if (panel === 'object') {
|
|
|
+ const src = distributions.value?.byObject ?? [];
|
|
|
+ categories = src.map(x => x.key);
|
|
|
+ values = src.map(x => x.count);
|
|
|
+ title = '受影响订单';
|
|
|
+ barColor = '#6366f1';
|
|
|
+ } else if (panel === 'process') {
|
|
|
+ const src = distributions.value?.byProcess ?? [];
|
|
|
+ categories = src.map(x => x.nodeName || x.key);
|
|
|
+ values = src.map(x => x.count);
|
|
|
+ title = '流程节点异常分布';
|
|
|
+ barColor = '#3b82f6';
|
|
|
+ } else if (panel === 'occurrence') {
|
|
|
+ const src = distributions.value?.byOccurrenceDept ?? [];
|
|
|
+ categories = src.map(x => x.deptName || String(x.key));
|
|
|
+ values = src.map(x => x.count);
|
|
|
+ title = '发生部门异常数';
|
|
|
+ barColor = '#f59e0b';
|
|
|
+ } else {
|
|
|
+ const src = deptBacklog.value;
|
|
|
+ categories = src.map(x => x.deptName || String(x.deptId));
|
|
|
+ values = src.map(x => x.pending);
|
|
|
+ title = '处理部门待处理积压';
|
|
|
+ barColor = '#ef4444';
|
|
|
+ }
|
|
|
+
|
|
|
+ mainChartEmpty.value = values.length === 0;
|
|
|
+ if (mainChartEmpty.value) return;
|
|
|
+
|
|
|
+ // 超出 8 条时用渐变色标注高值
|
|
|
+ const itemColors = values.map((v, i) => {
|
|
|
+ const maxV = Math.max(...values, 1);
|
|
|
+ const ratio = v / maxV;
|
|
|
+ return ratio >= 0.8 ? '#ef4444' : ratio >= 0.5 ? '#f59e0b' : barColor;
|
|
|
+ });
|
|
|
+
|
|
|
+ mainChart.setOption({
|
|
|
+ title: { text: title, left: 4, top: 6, textStyle: { fontSize: 14, fontWeight: 700, color: '#1e293b' } },
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
+ grid: { left: 140, right: 48, top: 44, bottom: 20 },
|
|
|
+ xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: categories,
|
|
|
+ axisLabel: { width: 128, overflow: 'truncate', color: '#475569', fontSize: 12 },
|
|
|
+ axisLine: { lineStyle: { color: '#e2e8f0' } },
|
|
|
+ },
|
|
|
+ series: [{
|
|
|
+ type: 'bar',
|
|
|
+ data: values.map((v, i) => ({ value: v, itemStyle: { color: itemColors[i], borderRadius: [0, 4, 4, 0] } })),
|
|
|
+ label: { show: true, position: 'right', color: '#64748b', fontSize: 12 },
|
|
|
+ barMaxWidth: 24,
|
|
|
+ }],
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ mainChart.off('click');
|
|
|
+ mainChart.on('click', (params: any) => {
|
|
|
+ chartDrillValue.value = categories[params.dataIndex] ?? '';
|
|
|
+ detailPage.value = 1;
|
|
|
+ void loadDetail();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ─── 右侧趋势图渲染 ──────────────────────────────────────────
|
|
|
+const STACK_COLORS = ['#6366f1','#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
|
|
|
+
|
|
|
+async function renderTrendChart() {
|
|
|
+ await nextTick();
|
|
|
+ if (!trendChartRef.value) return;
|
|
|
+ if (!trendChart) trendChart = echarts.init(trendChartRef.value);
|
|
|
+
|
|
|
+ const { dates, series } = dimTrendsData.value;
|
|
|
+ if (!series.length) {
|
|
|
+ trendChart.clear();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ trendChart.setOption({
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
+ legend: { bottom: 0, type: 'scroll', textStyle: { fontSize: 11 } },
|
|
|
+ grid: { left: 36, right: 8, top: 16, bottom: 48 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: dates.map(d => d.slice(5)), // "yyyy-MM-dd" → "MM-dd"
|
|
|
+ axisLabel: { fontSize: 11, rotate: 30 },
|
|
|
+ },
|
|
|
+ yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
|
|
+ series: series.map((s, i) => ({
|
|
|
+ name: s.name,
|
|
|
+ type: 'bar',
|
|
|
+ stack: 'total',
|
|
|
+ data: s.data,
|
|
|
+ itemStyle: { color: STACK_COLORS[i % STACK_COLORS.length] },
|
|
|
+ emphasis: { focus: 'series' },
|
|
|
+ })),
|
|
|
+ }, true);
|
|
|
+}
|
|
|
+
|
|
|
+// 面板 → dim 参数映射
|
|
|
+function panelToDim(panel: string) {
|
|
|
+ return { object: 'object', process: 'process', occurrence: 'occDept', responsible: 'respDept' }[panel] ?? 'object';
|
|
|
+}
|
|
|
+
|
|
|
+// 面板 → 中文标签(供模板使用)
|
|
|
+const panelDimLabel = computed(() => {
|
|
|
+ const map: Record<string, string> = { object: '订单', process: '流程节点', occurrence: '发生部门', responsible: '处理部门' };
|
|
|
+ return map[activePanel.value] ?? '';
|
|
|
+});
|
|
|
+
|
|
|
+const visibleKpiCards = computed(() => {
|
|
|
+ const visibleKeys = new Set(preferences.visibleKpiKeys);
|
|
|
+ return kpiCards.filter((card) => visibleKeys.has(card.key));
|
|
|
+});
|
|
|
+
|
|
|
+// ─── 数据加载 ─────────────────────────────────────────────────
|
|
|
+async function loadOverview() {
|
|
|
+ const data = await s8DashboardApi.overview();
|
|
|
+ Object.assign(overview, data);
|
|
|
+}
|
|
|
+
|
|
|
+async function loadChartData() {
|
|
|
+ const [dist, backlog] = await Promise.all([
|
|
|
+ s8DashboardApi.distributions(),
|
|
|
+ s8DashboardApi.deptBacklog(),
|
|
|
+ ]);
|
|
|
+ distributions.value = dist;
|
|
|
+ deptBacklog.value = backlog;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadDimTrends() {
|
|
|
+ dimTrendsData.value = await s8DashboardApi.dimTrends({
|
|
|
+ dim: panelToDim(activePanel.value),
|
|
|
+ days: preferences.trendDays,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function loadDetail() {
|
|
|
+ const params: Record<string, any> = {
|
|
|
+ page: detailPage.value,
|
|
|
+ pageSize: detailPageSize.value,
|
|
|
+ };
|
|
|
+ const filter = currentPanelFilter.value;
|
|
|
+ if (filter.severity) params.severity = filter.severity;
|
|
|
+ if (filter.dateStart) params.beginTime = filter.dateStart.toISOString();
|
|
|
+ if (filter.dateEnd) params.endTime = filter.dateEnd.toISOString();
|
|
|
+ if (filter.extra) {
|
|
|
+ if (activePanel.value === 'process') params.processNodeCode = filter.extra;
|
|
|
+ if (activePanel.value === 'occurrence') params.deptId = filter.extra;
|
|
|
+ if (activePanel.value === 'responsible') params.deptId = filter.extra;
|
|
|
+ }
|
|
|
+ if (chartDrillValue.value) {
|
|
|
+ if (activePanel.value === 'responsible') params.deptId = findDeptId(chartDrillValue.value, 'responsible');
|
|
|
+ if (activePanel.value === 'occurrence') params.deptId = findDeptId(chartDrillValue.value, 'occurrence');
|
|
|
+ if (activePanel.value === 'process') params.processNodeCode = findProcessCode(chartDrillValue.value);
|
|
|
+ if (activePanel.value === 'object') params.relatedObjectCode = chartDrillValue.value;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await service.get('/api/aidop/s8/exceptions', { params });
|
|
|
+ const d = (res as any).data ?? res;
|
|
|
+ detailList.value = Array.isArray(d.list) ? d.list : (Array.isArray(d) ? d : []);
|
|
|
+ detailTotal.value = d.total ?? detailList.value.length;
|
|
|
+ } catch {
|
|
|
+ detailList.value = [];
|
|
|
+ detailTotal.value = 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function findDeptId(name: string, type: 'responsible' | 'occurrence') {
|
|
|
+ const src = type === 'responsible' ? distributions.value?.byDept : distributions.value?.byOccurrenceDept;
|
|
|
+ return String(src?.find(d => d.deptName === name)?.key ?? '');
|
|
|
+}
|
|
|
+function findProcessCode(name: string) {
|
|
|
+ return distributions.value?.byProcess?.find(p => p.nodeName === name)?.key ?? name;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadAll() {
|
|
|
+ loading.value = true;
|
|
|
+ try {
|
|
|
+ await Promise.all([loadOverview(), loadChartData(), loadDimTrends()]);
|
|
|
+ await Promise.all([renderMainChart(), renderTrendChart()]);
|
|
|
+ await loadDetail();
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ lastRefreshedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── 筛选 ─────────────────────────────────────────────────────
|
|
|
+function onQuery() {
|
|
|
+ chartDrillValue.value = '';
|
|
|
+ detailPage.value = 1;
|
|
|
+ void loadAll();
|
|
|
+}
|
|
|
+function onReset() {
|
|
|
+ Object.assign(currentPanelFilter.value, createDefaultPanelFilter());
|
|
|
+ chartDrillValue.value = '';
|
|
|
+ detailPage.value = 1;
|
|
|
+ void loadAll();
|
|
|
+}
|
|
|
+
|
|
|
+// 面板切换时重绘图表 + 重载趋势数据
|
|
|
+watch(activePanel, async () => {
|
|
|
+ chartDrillValue.value = '';
|
|
|
+ detailPage.value = 1;
|
|
|
+ await loadDimTrends();
|
|
|
+ await Promise.all([renderMainChart(), renderTrendChart()]);
|
|
|
+ void loadDetail();
|
|
|
+});
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => preferences.sections.showTrendChart,
|
|
|
+ async () => {
|
|
|
+ await nextTick();
|
|
|
+ mainChart?.resize();
|
|
|
+ if (preferences.sections.showTrendChart) {
|
|
|
+ await renderTrendChart();
|
|
|
+ }
|
|
|
+ trendChart?.resize();
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+async function applyPreferences(nextConfig: DashboardPreferences) {
|
|
|
+ const previous = clonePreferences(preferences);
|
|
|
+ Object.assign(preferences, clonePreferences(nextConfig));
|
|
|
+ savePreferences(nextConfig);
|
|
|
+ if (activePanel.value !== nextConfig.defaultPanel) {
|
|
|
+ activePanel.value = nextConfig.defaultPanel;
|
|
|
+ } else {
|
|
|
+ if (previous.trendDays !== nextConfig.trendDays) {
|
|
|
+ await loadDimTrends();
|
|
|
+ }
|
|
|
+ await nextTick();
|
|
|
+ await Promise.all([renderMainChart(), preferences.sections.showTrendChart ? renderTrendChart() : Promise.resolve()]);
|
|
|
+ }
|
|
|
+ ElMessage.success('面板配置已生效');
|
|
|
+}
|
|
|
+
|
|
|
+function openConfigDrawer() {
|
|
|
+ configDrawerVisible.value = true;
|
|
|
+}
|
|
|
+
|
|
|
+function resetPreferences() {
|
|
|
+ void applyPreferences(clonePreferences(DEFAULT_PREFERENCES));
|
|
|
+}
|
|
|
+
|
|
|
+function onKpiClick(key: string) {
|
|
|
+ const q: Record<string, string> = {};
|
|
|
+ if (key === 'pending') q.bucket = 'pending';
|
|
|
+ if (key === 'inProgress') q.status = 'IN_PROGRESS';
|
|
|
+ if (key === 'timeout') q.timeoutFlag = 'true';
|
|
|
+ if (key === 'closed') q.status = 'CLOSED';
|
|
|
+ if (key === 'todayNew') q.todayNew = 'true';
|
|
|
+ if (Object.keys(q).length) router.push({ path: '/aidop/s8/exceptions', query: q });
|
|
|
+}
|
|
|
+
|
|
|
+function onDetailPageChange(page: number) {
|
|
|
+ detailPage.value = page;
|
|
|
+ void loadDetail();
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => void loadAll());
|
|
|
+onBeforeUnmount(() => { mainChart?.dispose(); trendChart?.dispose(); });
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <AidopDemoShell title="异常监控看板" subtitle="S8 异常协同 / KPI、趋势图、分布图">
|
|
|
+ <div v-loading="loading" class="s8-db">
|
|
|
+
|
|
|
+ <div class="s8-toolbar">
|
|
|
+ <div class="s8-toolbar__meta">
|
|
|
+ <span class="s8-toolbar__label">当前面板:{{ panels.find((item) => item.key === activePanel)?.label }}</span>
|
|
|
+ <span class="s8-toolbar__hint">支持按面板维护筛选条件</span>
|
|
|
+ </div>
|
|
|
+ <div class="s8-toolbar__actions">
|
|
|
+ <div v-if="preferences.sections.showRefreshBar" class="s8-refresh-bar">
|
|
|
+ <span class="s8-refresh-time">最近刷新:{{ lastRefreshedAt || '—' }}</span>
|
|
|
+ <el-button size="small" plain @click="onQuery">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ <el-button size="small" @click="openConfigDrawer">面板配置</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ② 筛选栏 -->
|
|
|
+ <div v-if="preferences.sections.showFilterBar" class="s8-filter-bar">
|
|
|
+ <el-form inline class="s8-filter-form">
|
|
|
+ <el-form-item label="严重度">
|
|
|
+ <el-select v-model="currentPanelFilter.severity" clearable placeholder="全部" style="width:120px">
|
|
|
+ <el-option label="严重" value="CRITICAL" />
|
|
|
+ <el-option label="高" value="HIGH" />
|
|
|
+ <el-option label="中" value="MEDIUM" />
|
|
|
+ <el-option label="低" value="LOW" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="开始日期">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="currentPanelFilter.dateStart"
|
|
|
+ type="date"
|
|
|
+ placeholder="选择日期"
|
|
|
+ style="width:130px"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="结束日期">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="currentPanelFilter.dateEnd"
|
|
|
+ type="date"
|
|
|
+ placeholder="选择日期"
|
|
|
+ style="width:130px"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-if="activePanel === 'process'" label="流程节点">
|
|
|
+ <el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
|
|
|
+ <el-option v-for="o in processNodeOptions" :key="o.value" :label="o.label" :value="o.value" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-if="activePanel === 'occurrence'" label="发生部门">
|
|
|
+ <el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
|
|
|
+ <el-option v-for="o in occurrenceDeptOptions" :key="o.value" :label="o.label" :value="o.value" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-if="activePanel === 'responsible'" label="处理部门">
|
|
|
+ <el-select v-model="currentPanelFilter.extra" clearable placeholder="全部" style="width:130px">
|
|
|
+ <el-option v-for="o in responsibleDeptOptions" :key="o.value" :label="o.label" :value="o.value" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" @click="onQuery">查询</el-button>
|
|
|
+ <el-button @click="onReset">重置</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ③ 面板 Tabs -->
|
|
|
+ <el-tabs v-model="activePanel" type="card" class="s8-tabs">
|
|
|
+ <el-tab-pane v-for="p in panels" :key="p.key" :label="p.label" :name="p.key" />
|
|
|
+ </el-tabs>
|
|
|
+
|
|
|
+ <!-- ④ KPI × 8 -->
|
|
|
+ <div v-if="preferences.sections.showKpiSection" class="s8-kpi-grid">
|
|
|
+ <div v-for="card in visibleKpiCards" :key="card.key">
|
|
|
+ <div class="s8-kpi" :style="{ '--kpi-color': card.color }" @click="onKpiClick(card.key)">
|
|
|
+ <div class="s8-kpi__label">{{ card.label }}</div>
|
|
|
+ <div class="s8-kpi__value">
|
|
|
+ {{ overview[card.key as keyof typeof overview] ?? 0 }}{{ card.suffix }}
|
|
|
+ </div>
|
|
|
+ <div class="s8-kpi__bar"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ⑤ 主内容:左图 + 右 Top N -->
|
|
|
+ <el-row :gutter="12" class="s8-main-row">
|
|
|
+ <el-col :span="preferences.sections.showTrendChart ? 16 : 24">
|
|
|
+ <div class="s8-chart-wrap">
|
|
|
+ <!-- 有数据:图表 -->
|
|
|
+ <div v-show="!mainChartEmpty" ref="mainChartRef" class="s8-main-chart"></div>
|
|
|
+ <!-- 无数据:空态提示 -->
|
|
|
+ <div v-if="mainChartEmpty" class="s8-chart-empty">
|
|
|
+ <el-empty description="暂无数据">
|
|
|
+ <template #description>
|
|
|
+ <p style="color:#64748b;font-size:13px">当前面板暂无数据</p>
|
|
|
+ <p v-if="activePanel === 'object' || activePanel === 'process'"
|
|
|
+ style="color:#94a3b8;font-size:12px;margin-top:4px">
|
|
|
+ 请先执行数据库初始化脚本,为异常记录分配流程节点和关联对象
|
|
|
+ </p>
|
|
|
+ </template>
|
|
|
+ </el-empty>
|
|
|
+ </div>
|
|
|
+ <!-- 下钻提示 -->
|
|
|
+ <div v-if="chartDrillValue" class="s8-drill-hint">
|
|
|
+ 已下钻至:<strong>{{ chartDrillValue }}</strong>
|
|
|
+ <el-button link size="small" style="margin-left:8px"
|
|
|
+ @click="() => { chartDrillValue = ''; void loadDetail(); }">
|
|
|
+ 清除筛选
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <el-col v-if="preferences.sections.showTrendChart" :span="8">
|
|
|
+ <div class="s8-trend-wrap">
|
|
|
+ <div class="s8-trend-header">
|
|
|
+ 每日异常趋势
|
|
|
+ <span class="s8-trend-sub">(近 {{ preferences.trendDays }} 天,按{{ panelDimLabel }}分组)</span>
|
|
|
+ </div>
|
|
|
+ <div ref="trendChartRef" class="s8-trend-chart"></div>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- ⑥ 底部明细表 -->
|
|
|
+ <div v-if="preferences.sections.showDetailTable" class="s8-detail-wrap">
|
|
|
+ <div class="s8-detail-header">
|
|
|
+ 异常明细
|
|
|
+ <span v-if="chartDrillValue" class="s8-detail-drill">(已筛选:{{ chartDrillValue }})</span>
|
|
|
+ <span class="s8-detail-total">共 {{ detailTotal }} 条</span>
|
|
|
+ </div>
|
|
|
+ <el-table :data="detailList" size="small" border stripe style="width:100%">
|
|
|
+ <el-table-column prop="exceptionCode" label="编号" width="140" />
|
|
|
+ <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
|
|
+ <el-table-column label="状态" width="96" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="statusTagType(row.status)" size="small" effect="light">
|
|
|
+ {{ row.statusLabel || row.status }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="严重度" width="90" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="severityTagType(row.severity)" size="small" effect="dark">
|
|
|
+ {{ row.severityLabel || row.severity }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="responsibleDeptName" label="责任部门" width="120" show-overflow-tooltip />
|
|
|
+ <el-table-column label="SLA截止" width="148" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <span :class="row.timeoutFlag ? 's8-timeout-text' : ''">
|
|
|
+ {{ fmtDate(row.slaDeadline) }}
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="超时" width="64" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag v-if="row.timeoutFlag" type="danger" size="small" effect="dark">是</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="创建时间" width="148" align="center">
|
|
|
+ <template #default="{ row }">{{ fmtDate(row.createdAt) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="68" fixed="right" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button link type="primary" size="small"
|
|
|
+ @click="router.push({ path: '/aidop/s8/exceptions', query: { id: String(row.id) } })">
|
|
|
+ 查看
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ <el-pagination
|
|
|
+ class="s8-pagination"
|
|
|
+ background
|
|
|
+ layout="total, prev, pager, next"
|
|
|
+ :total="detailTotal"
|
|
|
+ :page-size="detailPageSize"
|
|
|
+ :current-page="detailPage"
|
|
|
+ @current-change="onDetailPageChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <S8DashboardConfigDrawer
|
|
|
+ v-model="configDrawerVisible"
|
|
|
+ :config="preferences"
|
|
|
+ :kpi-options="kpiCards"
|
|
|
+ @save="applyPreferences"
|
|
|
+ @reset="resetPreferences"
|
|
|
+ />
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </AidopDemoShell>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* ─── 容器 ─────────────────────────────────────────────── */
|
|
|
+.s8-db {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ padding-bottom: 28px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── 工具栏 / 刷新栏 ────────────────────────────────── */
|
|
|
+.s8-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.s8-toolbar__meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ color: #475569;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.s8-toolbar__label { font-weight: 600; color: #0f172a; }
|
|
|
+.s8-toolbar__hint { color: #94a3b8; }
|
|
|
+.s8-toolbar__actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.s8-refresh-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.s8-refresh-time { font-size: 12px; color: #94a3b8; }
|
|
|
+
|
|
|
+/* ─── 筛选栏 ─────────────────────────────────────────── */
|
|
|
+.s8-filter-bar {
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 10px 16px 2px;
|
|
|
+}
|
|
|
+.s8-filter-form :deep(.el-form-item) { margin-bottom: 8px; }
|
|
|
+
|
|
|
+/* ─── 面板 Tabs ──────────────────────────────────────── */
|
|
|
+.s8-tabs { margin-bottom: 0; }
|
|
|
+.s8-tabs :deep(.el-tabs__item) { font-weight: 500; }
|
|
|
+.s8-tabs :deep(.el-tabs__item.is-active) { color: #6366f1; border-bottom-color: #6366f1; }
|
|
|
+
|
|
|
+/* ─── KPI 卡片 ───────────────────────────────────────── */
|
|
|
+.s8-kpi-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.s8-kpi {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 14px 16px 10px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: box-shadow .2s, transform .15s;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.s8-kpi:hover { box-shadow: 0 4px 16px rgba(0,0,0,.10); transform: translateY(-2px); }
|
|
|
+.s8-kpi__label { font-size: 12px; color: #64748b; margin-bottom: 6px; }
|
|
|
+.s8-kpi__value { font-size: 26px; font-weight: 700; color: var(--kpi-color); line-height: 1.1; }
|
|
|
+.s8-kpi__bar {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 0; left: 0; right: 0;
|
|
|
+ height: 3px;
|
|
|
+ background: var(--kpi-color);
|
|
|
+ opacity: .25;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── 主内容区 ──────────────────────────────────────── */
|
|
|
+.s8-main-row { align-items: stretch; }
|
|
|
+.s8-chart-wrap {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px;
|
|
|
+ min-height: 360px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.s8-main-chart { width: 100%; height: 340px; flex: 1; }
|
|
|
+.s8-chart-empty { flex: 1; display: flex; align-items: center; justify-content: center; }
|
|
|
+.s8-drill-hint {
|
|
|
+ margin-top: 8px;
|
|
|
+ padding: 4px 10px;
|
|
|
+ background: #eff6ff;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #3b82f6;
|
|
|
+}
|
|
|
+
|
|
|
+/* ─── 右侧趋势图 ──────────────────────────────────── */
|
|
|
+.s8-trend-wrap {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.s8-trend-header {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #1e293b;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.s8-trend-sub { font-size: 12px; color: #94a3b8; font-weight: normal; margin-left: 4px; }
|
|
|
+.s8-trend-chart { flex: 1; min-height: 300px; }
|
|
|
+
|
|
|
+/* ─── 明细表 ──────────────────────────────────────── */
|
|
|
+.s8-detail-wrap {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 14px 16px;
|
|
|
+}
|
|
|
+.s8-detail-header {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #1e293b;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.s8-detail-drill { font-size: 12px; color: #6366f1; font-weight: normal; }
|
|
|
+.s8-detail-total { margin-left: auto; font-size: 12px; color: #94a3b8; font-weight: normal; }
|
|
|
+.s8-timeout-text { color: #ef4444; font-weight: 600; }
|
|
|
+.s8-pagination { margin-top: 12px; justify-content: flex-end; }
|
|
|
+</style>
|