Explorar el Código

feat(kpi): add S7_L1_001 configurable filters and order drilldown

Frontend closeout (user-facing) for S7_L1_001 dimension results. Only this
metric; other 8 KPIs untouched. No backend change.

- kpiDimensionConfigApi.ts: typed dimension config/capability/detail/run API
  (mirrors service({...}) + .data.result unwrap)
- S7OrderCycleDimensionDrawer.vue: order drilldown drawer; top aggregate uses
  backend aggregateValue (no front-end re-average); paged detail + sort +
  date/order filter; distinct empty states (pending / version_mismatch /
  no_data / failed)
- DynamicModuleDashboard.vue: S7_L1_001-gated 查看订单明细 entry + per-metric
  filterMode label (uses metric.filterMode, not module scope) + drawer mount
- bump version Web 2.4.268

Note: admin dimension config panel is a follow-up slice (not in this commit).
YY968XX hace 1 semana
padre
commit
49584e6b11

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-  "version": "2.4.267",
+  "version": "2.4.268",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 168 - 0
Web/src/views/aidop/api/kpiDimensionConfigApi.ts

@@ -0,0 +1,168 @@
+import service from '/@/utils/request';
+
+const BASE = '/api/AdoSmartOpsKpiDimensionConfig';
+
+/** 维度配置版本(后端 DTO 对齐) */
+export interface KpiDimensionConfig {
+	id: string | number;
+	tenantId?: string | number | null;
+	metricCode: string;
+	moduleCode: string;
+	summaryConfigId: string | number;
+	summaryConfigVersion: number;
+	dimensionConfigVersion: number;
+	dataSourceCode?: string | null;
+	sqlScript?: string | null;
+	aggregationType: string;
+	supportedDimensionsJson?: string | null;
+	timeoutSeconds: number;
+	publishStatus: string;
+	isCurrent: boolean;
+	businessSqlSource?: string | null;
+	changeRemark?: string | null;
+	remark?: string | null;
+	createdBy?: string | null;
+	createdAt?: string | null;
+	publishedBy?: string | null;
+	publishedAt?: string | null;
+}
+
+/** 维度能力(驱动前端筛选/下钻可用性) */
+export interface KpiDimensionCapability {
+	metricCode: string;
+	moduleCode: string;
+	supported: boolean;
+	capabilityStatus: string; // PENDING_DIMENSION_SQL / CONFIGURED / SUMMARY_ONLY
+	summaryConfigVersion?: number | null;
+	dimensionConfigVersion?: number | null;
+	supportedDimensions: string[];
+	aggregationType?: string | null;
+	lastRunStatus?: string | null;
+	lastRunAt?: string | null;
+}
+
+/** 维度明细单行 */
+export interface KpiDimensionDetailRow {
+	valueDate: string;
+	dimensionType: string;
+	dimensionCode: string;
+	dimensionName?: string | null;
+	metricValue?: number | null;
+	numerator?: number | null;
+	denominator?: number | null;
+	sumValue?: number | null;
+	sampleCount?: number | null;
+	sourceKey?: string | null;
+	batchId?: string | null;
+}
+
+/** 维度明细查询结果(含按 AggregationType 复算的聚合值) */
+export interface KpiDimensionDetailResult {
+	metricCode: string;
+	status: string; // OK / NOT_CONFIGURED
+	summaryConfigVersion?: number | null;
+	dimensionConfigVersion?: number | null;
+	aggregationType?: string | null;
+	aggregateValue?: number | null;
+	total: number;
+	list: KpiDimensionDetailRow[];
+}
+
+/** 试算结果 */
+export interface KpiDimensionPreviewResult {
+	ok: boolean;
+	metricCode: string;
+	dataSourceCode?: string | null;
+	bizDate: string;
+	validationStatus: string;
+	resultStatus: string;
+	durationMs: number;
+	rowCount: number;
+	outputColumns: string[];
+	supportedDimensions: string[];
+	sampleRows: Record<string, any>[];
+	errorCode?: string | null;
+	errorMessage?: string | null;
+	executedParameters: string;
+}
+
+/** 某 KPI 的全部维度配置版本 */
+export function getDimensionConfigByMetric(metricCode: string, moduleCode: string) {
+	return service({ url: `${BASE}/by-metric/${encodeURIComponent(metricCode)}`, method: 'get', params: { moduleCode } });
+}
+
+/** 维度能力 */
+export function getDimensionCapability(metricCode: string, moduleCode: string) {
+	return service({ url: `${BASE}/capability/${encodeURIComponent(metricCode)}`, method: 'get', params: { moduleCode } });
+}
+
+/** 已登记数据源 */
+export function getDimensionDataSources() {
+	return service({ url: `${BASE}/data-sources`, method: 'get' });
+}
+
+/** 新增草稿 */
+export function createDimensionDraft(data: any) {
+	return service({ url: BASE, method: 'post', data });
+}
+
+/** 编辑草稿 */
+export function updateDimensionDraft(id: number | string, data: any) {
+	return service({ url: `${BASE}/${id}`, method: 'put', data });
+}
+
+/** 删除草稿 */
+export function deleteDimensionConfig(id: number | string) {
+	return service({ url: `${BASE}/${id}`, method: 'delete' });
+}
+
+/** SQL 安全校验 */
+export function validateDimensionSql(sqlScript: string) {
+	return service({ url: `${BASE}/validate`, method: 'post', data: { sqlScript } });
+}
+
+/** 试算 */
+export function previewDimensionConfig(data: any) {
+	return service({ url: `${BASE}/preview`, method: 'post', data, timeout: 90000 });
+}
+
+/** 发布 */
+export function publishDimensionConfig(id: number | string) {
+	return service({ url: `${BASE}/${id}/publish`, method: 'post' });
+}
+
+/** 激活/回滚 */
+export function activateDimensionConfig(id: number | string) {
+	return service({ url: `${BASE}/${id}/activate`, method: 'post' });
+}
+
+/** 退役 */
+export function retireDimensionConfig(id: number | string) {
+	return service({ url: `${BASE}/${id}/retire`, method: 'post' });
+}
+
+/** 手动运行一次 */
+export function runDimension(metricCode: string, moduleCode: string, valueDate?: string) {
+	return service({ url: `${BASE}/run/${encodeURIComponent(metricCode)}`, method: 'post', params: { moduleCode, valueDate }, timeout: 90000 });
+}
+
+/** 运行日志(分页) */
+export function getDimensionRunLog(metricCode: string, params: { moduleCode: string; page?: number; pageSize?: number; status?: string }) {
+	return service({ url: `${BASE}/run-log/${encodeURIComponent(metricCode)}`, method: 'get', params });
+}
+
+/** 维度明细(下钻) */
+export function getDimensionDetail(metricCode: string, params: {
+	moduleCode: string;
+	startDate?: string;
+	endDate?: string;
+	dimensionType?: string;
+	dimensionCode?: string;
+	orderNo?: string;
+	sortField?: string;
+	sortOrder?: string;
+	page?: number;
+	pageSize?: number;
+}) {
+	return service({ url: `${BASE}/detail/${encodeURIComponent(metricCode)}`, method: 'get', params });
+}

+ 51 - 0
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -73,6 +73,10 @@
 								<span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
 							</div>
 						</div>
+						<div v-if="isS7L1001(widget)" class="dimension-affordance" @click.stop>
+							<span v-if="dimFilterLabel(widget.metrics[0])" class="dimension-filter-tag" :class="dimFilterClass(widget.metrics[0])">{{ dimFilterLabel(widget.metrics[0]) }}</span>
+							<el-button link type="primary" size="small" @click.stop="openOrderDrilldown(widget)">查看订单明细</el-button>
+						</div>
 					</article>
 				</div>
 			</section>
@@ -271,6 +275,14 @@
 			:filters="chatBIFilters"
 			:suggestions="chatBISuggestions"
 		/>
+		<S7OrderCycleDimensionDrawer
+			v-model="orderDrilldownVisible"
+			:module-code="moduleCode"
+			:initial-filter-mode="orderDrilldownFilterMode"
+			:initial-start-date="dashFilter.dateStart"
+			:initial-end-date="dashFilter.dateEnd"
+			:initial-order-no="dashFilter.orderNo"
+		/>
 	</div>
 </template>
 
@@ -282,6 +294,7 @@ import * as echarts from 'echarts';
 import DetailQueryBar from './DetailQueryBar.vue';
 import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
 import ChatBIFloat from '../../chatbi/ChatBIFloat.vue';
+import S7OrderCycleDimensionDrawer from './S7OrderCycleDimensionDrawer.vue';
 import {
 	fetchDynamicDashboardPage,
 	type DynamicDashboardMetric,
@@ -320,6 +333,38 @@ const activeL2MetricCode = ref('');
 const activeL3MetricCode = ref('');
 const pageTabs = computed(() => page.value?.tabs || []);
 const chatBIFilters = computed(() => buildExtraParams());
+// S7_L1_001 配置化维度下钻(仅此码;用每指标 filterMode 判断,不用模块级 scope)
+const orderDrilldownVisible = ref(false);
+const orderDrilldownFilterMode = ref<string | null>(null);
+const dashFilter = computed<any>(() => buildExtraParams());
+function isS7L1001(widget: any) {
+	return widget?.tabMetricCode === 'S7_L1_001' || widget?.metrics?.[0]?.metricCode === 'S7_L1_001';
+}
+function dimFilterLabel(m: any) {
+	if (!m || m.metricCode !== 'S7_L1_001') return '';
+	switch (m.filterMode) {
+		case 'configurable_dimension':
+			return '已按筛选';
+		case 'dimension_no_data':
+			return '筛选范围暂无数据';
+		case 'version_mismatch':
+			return '配置版本不一致';
+		case 'filter_not_configured':
+		case 'not_configured':
+			return '未配置维度';
+		default:
+			return '';
+	}
+}
+function dimFilterClass(m: any) {
+	if (m?.filterMode === 'configurable_dimension') return 'is-dim-ok';
+	if (m?.filterMode === 'version_mismatch') return 'is-dim-warn';
+	return 'is-dim-muted';
+}
+function openOrderDrilldown(widget: any) {
+	orderDrilldownFilterMode.value = widget?.metrics?.[0]?.filterMode ?? null;
+	orderDrilldownVisible.value = true;
+}
 const scopeLabel = computed(() => {
 	const scope = page.value?.dataSource?.scope;
 	if (!scope || scope === 'module_summary') return '';
@@ -1132,4 +1177,10 @@ watch(page, () => renderChartsSoon());
 		grid-column: span 12 !important;
 	}
 }
+
+.dimension-affordance { display: flex; align-items: center; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
+.dimension-filter-tag { font-size: 11px; padding: 1px 8px; border-radius: 10px; line-height: 18px; }
+.dimension-filter-tag.is-dim-ok { color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
+.dimension-filter-tag.is-dim-warn { color: var(--el-color-warning); background: var(--el-color-warning-light-9); }
+.dimension-filter-tag.is-dim-muted { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); }
 </style>

+ 213 - 0
Web/src/views/aidop/kanban/components/S7OrderCycleDimensionDrawer.vue

@@ -0,0 +1,213 @@
+<template>
+	<el-drawer v-model="innerVisible" :size="820" direction="rtl" @closed="onClosed">
+		<template #header>
+			<div class="drawer-head">
+				<span class="drawer-title">订单发货周期 · 订单明细</span>
+				<span class="drawer-sub">S7_L1_001 配置化维度下钻</span>
+			</div>
+		</template>
+
+		<div v-loading="loading" class="dim-drawer">
+			<!-- 顶部摘要:聚合值来自后端,前端不复算 -->
+			<el-descriptions v-if="capState === 'configured'" :column="4" border size="small" class="dim-summary">
+				<el-descriptions-item label="当前筛选平均发货周期">
+					<span class="agg-value">{{ aggregateDisplay }}</span>
+				</el-descriptions-item>
+				<el-descriptions-item label="订单样本">{{ detail?.total ?? 0 }}</el-descriptions-item>
+				<el-descriptions-item label="汇总版本">v{{ detail?.summaryConfigVersion ?? '-' }}</el-descriptions-item>
+				<el-descriptions-item label="维度版本">v{{ detail?.dimensionConfigVersion ?? '-' }}</el-descriptions-item>
+			</el-descriptions>
+
+			<!-- 筛选 -->
+			<div v-if="capState === 'configured'" class="dim-filter">
+				<el-date-picker
+					v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" size="small"
+					start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" style="width: 260px" />
+				<el-input v-model="orderNo" placeholder="订单号" clearable size="small" style="width: 180px" />
+				<el-button size="small" type="primary" @click="onQuery">查询</el-button>
+				<el-button size="small" @click="onReset">重置</el-button>
+				<el-button size="small" :icon="RefreshRight" @click="loadDetail">刷新</el-button>
+			</div>
+
+			<!-- 明细表 -->
+			<el-table
+				v-if="capState === 'configured' && (detail?.list?.length ?? 0) > 0"
+				:data="detail!.list" size="small" border stripe class="dim-table"
+				@sort-change="onSortChange">
+				<el-table-column prop="valueDate" label="日期" width="120" sortable="custom" />
+				<el-table-column prop="dimensionCode" label="订单号" min-width="160" />
+				<el-table-column prop="metricValue" label="发货周期(天)" width="130" sortable="custom" align="right">
+					<template #default="{ row }">{{ fmt(row.metricValue) }}</template>
+				</el-table-column>
+				<el-table-column prop="sumValue" label="周期合计" width="110" align="right">
+					<template #default="{ row }">{{ fmt(row.sumValue) }}</template>
+				</el-table-column>
+				<el-table-column prop="sampleCount" label="样本数" width="90" align="right" />
+				<el-table-column prop="batchId" label="批次" min-width="200" show-overflow-tooltip />
+			</el-table>
+
+			<!-- 空态:区分技术状态,不统一“暂无数据” -->
+			<el-empty v-else-if="capState === 'pending'" description="当前 KPI 尚未配置维度 SQL" />
+			<el-empty v-else-if="capState === 'version_mismatch'" :image-size="90">
+				<template #description>配置版本不一致,请重新发布维度配置</template>
+			</el-empty>
+			<el-empty v-else-if="capState === 'failed'" description="维度数据计算失败" />
+			<el-empty v-else-if="capState === 'configured'" description="当前筛选范围暂无数据" />
+
+			<el-pagination
+				v-if="capState === 'configured' && (detail?.total ?? 0) > 0"
+				class="dim-pager" layout="total, prev, pager, next, sizes" background
+				:total="detail!.total" :page-size="pageSize" :current-page="page"
+				:page-sizes="[10, 20, 50, 100]"
+				@current-change="onPageChange" @size-change="onSizeChange" />
+		</div>
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import { RefreshRight } from '@element-plus/icons-vue';
+import { getDimensionCapability, getDimensionDetail, type KpiDimensionDetailResult } from '/@/views/aidop/api/kpiDimensionConfigApi';
+
+const props = defineProps<{
+	modelValue: boolean;
+	moduleCode?: string;
+	initialFilterMode?: string | null;
+	initialStartDate?: string | null;
+	initialEndDate?: string | null;
+	initialOrderNo?: string | null;
+}>();
+const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'closed'): void }>();
+
+const METRIC = 'S7_L1_001';
+const moduleCode = computed(() => props.moduleCode || 'S7');
+
+const innerVisible = computed({
+	get: () => props.modelValue,
+	set: (v: boolean) => emit('update:modelValue', v),
+});
+
+const loading = ref(false);
+const detail = ref<KpiDimensionDetailResult | null>(null);
+const capState = ref<'pending' | 'configured' | 'version_mismatch' | 'failed'>('configured');
+const dateRange = ref<[string, string] | null>(null);
+const orderNo = ref('');
+const page = ref(1);
+const pageSize = ref(20);
+const sortField = ref('value_date');
+const sortOrder = ref('desc');
+
+function unwrap(res: any) {
+	return (res as any)?.data?.result ?? (res as any)?.data ?? res;
+}
+function fmt(v: number | null | undefined) {
+	return v === null || v === undefined ? '—' : v;
+}
+const aggregateDisplay = computed(() => {
+	const v = detail.value?.aggregateValue;
+	return v === null || v === undefined ? '—' : `${v} 天`;
+});
+
+async function loadDetail() {
+	loading.value = true;
+	try {
+		// 能力优先:未配置直接空态,不请求明细
+		const cap = unwrap(await getDimensionCapability(METRIC, moduleCode.value));
+		if (!cap || cap.supported !== true) {
+			capState.value = 'pending';
+			detail.value = null;
+			return;
+		}
+		// 版本不匹配以卡片传入 filterMode 为准(详情端点只读当前版本,不作版本裁决)
+		if (props.initialFilterMode === 'version_mismatch') {
+			capState.value = 'version_mismatch';
+			detail.value = null;
+			return;
+		}
+		const res = unwrap(await getDimensionDetail(METRIC, {
+			moduleCode: moduleCode.value,
+			startDate: dateRange.value?.[0] || undefined,
+			endDate: dateRange.value?.[1] || undefined,
+			orderNo: orderNo.value.trim() || undefined,
+			sortField: sortField.value,
+			sortOrder: sortOrder.value,
+			page: page.value,
+			pageSize: pageSize.value,
+		}));
+		if (!res || res.status === 'NOT_CONFIGURED') {
+			capState.value = 'pending';
+			detail.value = null;
+			return;
+		}
+		capState.value = 'configured';
+		detail.value = res;
+	} catch (e: any) {
+		capState.value = 'failed';
+		detail.value = null;
+		ElMessage.error('加载订单明细失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		loading.value = false;
+	}
+}
+
+function onQuery() {
+	page.value = 1;
+	loadDetail();
+}
+function onReset() {
+	dateRange.value = null;
+	orderNo.value = '';
+	page.value = 1;
+	sortField.value = 'value_date';
+	sortOrder.value = 'desc';
+	loadDetail();
+}
+function onPageChange(p: number) {
+	page.value = p;
+	loadDetail();
+}
+function onSizeChange(s: number) {
+	pageSize.value = s;
+	page.value = 1;
+	loadDetail();
+}
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	sortField.value = prop === 'metricValue' ? 'metric_value' : 'value_date';
+	sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
+	page.value = 1;
+	loadDetail();
+}
+function onClosed() {
+	// 关闭清理,避免残留上次状态
+	detail.value = null;
+	capState.value = 'configured';
+	emit('closed');
+}
+
+watch(
+	() => props.modelValue,
+	(v) => {
+		if (v) {
+			dateRange.value = props.initialStartDate && props.initialEndDate ? [props.initialStartDate, props.initialEndDate] : null;
+			orderNo.value = props.initialOrderNo || '';
+			page.value = 1;
+			sortField.value = 'value_date';
+			sortOrder.value = 'desc';
+			loadDetail();
+		}
+	}
+);
+</script>
+
+<style scoped>
+.drawer-head { display: flex; flex-direction: column; }
+.drawer-title { font-size: 15px; font-weight: 600; }
+.drawer-sub { font-size: 12px; color: var(--el-text-color-secondary); }
+.dim-drawer { display: flex; flex-direction: column; gap: 12px; }
+.dim-summary { margin-bottom: 4px; }
+.agg-value { font-size: 16px; font-weight: 600; color: var(--el-color-primary); }
+.dim-filter { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.dim-table { margin-top: 4px; }
+.dim-pager { margin-top: 8px; justify-content: flex-end; }
+</style>