Bladeren bron

feat(kpi): 通用化 S5/S6/S7 KPI 维度配置前端(管理员面板 + capability 驱动筛选下钻)(Web 2.4.270)

将已具备后端维度能力的 6 个 KPI 平台化为可维护/可筛选/可下钻的通用前端:
- KpiDimensionConfigPanel.vue:kpiMaster 新增「维度配置」Tab,管理员查看当前配置/版本列表/维度运行日志,支持载入/复制新版本/校验/试算/保存草稿/发布/激活/退役/运行一次;已发布只读、草稿可编辑;版本绑定不一致告警并禁激活。
- KpiDimensionFilterPanel.vue:capability 驱动筛选,仅渲染各 KPI 声明维度(日期 + 单业务维),业务维统一映射 dimensionCode。
- KpiDimensionDrawer.vue:通用下钻抽屉,动态列(比率 KPI 显示分子/分母),顶部聚合值取后端 aggregateValue 不复算,NO_DATA/未配置/版本不匹配/失败状态区分显示,NULL 显示 — 而非 0。
- DynamicModuleDashboard.vue:filterMode 展示通用化 + capability 状态管理(进入模块并发拉取、防竞态);受管码卡片显示「查看明细」,S7_L1_001 保留专用抽屉回归,其余走通用抽屉。
- kpiDimensionConfigApi.ts:补 KpiDimensionRunLog/KpiDimensionDetailQuery 类型;kpiDimensionConsts.ts 统一维度字段/状态/标签常量。
不为每个 KPI 建独立页面;不改任何业务公式;不提前接入 3 个人效 KPI。
YY968XX 1 week geleden
bovenliggende
commit
683ff2a36c

+ 1 - 1
Web/package.json

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

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

@@ -68,6 +68,35 @@ export interface KpiDimensionDetailResult {
 	list: KpiDimensionDetailRow[];
 }
 
+/** 维度运行日志单行 */
+export interface KpiDimensionRunLog {
+	id?: string | number;
+	batchId?: string | null;
+	dimensionConfigVersion?: number | null;
+	summaryConfigVersion?: number | null;
+	valueDate?: string | null;
+	status?: string | null; // SUCCESS / NO_DATA / FAILED
+	rowCount?: number | null;
+	startTime?: string | null;
+	endTime?: string | null;
+	durationMs?: number | null;
+	errorMessage?: string | null;
+}
+
+/** 维度明细查询入参(业务维统一走 dimensionCode,前端只发 capability 声明维度) */
+export interface KpiDimensionDetailQuery {
+	moduleCode: string;
+	startDate?: string;
+	endDate?: string;
+	dimensionType?: string;
+	dimensionCode?: string;
+	orderNo?: string;
+	sortField?: string;
+	sortOrder?: string;
+	page?: number;
+	pageSize?: number;
+}
+
 /** 试算结果 */
 export interface KpiDimensionPreviewResult {
 	ok: boolean;

+ 99 - 26
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -73,9 +73,9 @@
 								<span v-if="widget.metrics[0]?.gapLabel">期量差 {{ widget.metrics[0]?.gapLabel }}</span>
 							</div>
 						</div>
-						<div v-if="isS7L1001(widget)" class="dimension-affordance" @click.stop>
+						<div v-if="isDimensionManaged(widget.metrics[0])" 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>
+							<el-button link type="primary" size="small" @click.stop="openDrilldown(widget)">查看明细</el-button>
 						</div>
 					</article>
 				</div>
@@ -112,6 +112,10 @@
 								<span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
 							</div>
 						</div>
+						<div v-if="isDimensionManaged(metric)" class="dimension-affordance" @click.stop>
+							<span v-if="dimFilterLabel(metric)" class="dimension-filter-tag" :class="dimFilterClass(metric)">{{ dimFilterLabel(metric) }}</span>
+							<el-button link type="primary" size="small" @click.stop="openDrilldown(metric)">查看明细</el-button>
+						</div>
 						<div v-if="formulaText(metric)" class="metric-formula">{{ formulaText(metric) }}</div>
 					</article>
 				</div>
@@ -283,6 +287,18 @@
 			:initial-end-date="dashFilter.dateEnd"
 			:initial-order-no="dashFilter.orderNo"
 		/>
+		<KpiDimensionDrawer
+			v-model="genericDrilldownVisible"
+			:metric-code="genericDrilldownMetric?.metricCode || ''"
+			:metric-name="genericDrilldownMetric?.metricName"
+			:unit="genericDrilldownMetric?.unit"
+			:module-code="moduleCode"
+			:capability="genericDrilldownCap"
+			:initial-filter-mode="genericDrilldownFilterMode"
+			:initial-start-date="dashFilter.dateStart"
+			:initial-end-date="dashFilter.dateEnd"
+			:initial-dim-value="genericInitialDimValue"
+		/>
 	</div>
 </template>
 
@@ -295,6 +311,9 @@ import DetailQueryBar from './DetailQueryBar.vue';
 import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
 import ChatBIFloat from '../../chatbi/ChatBIFloat.vue';
 import S7OrderCycleDimensionDrawer from './S7OrderCycleDimensionDrawer.vue';
+import KpiDimensionDrawer from './KpiDimensionDrawer.vue';
+import { getDimensionCapability, type KpiDimensionCapability } from '../../api/kpiDimensionConfigApi';
+import { FILTER_MODE_LABEL, FILTER_MODE, DIM_TO_CARD_PARAM, businessDimensions, unwrapDim } from './kpiDimensionConsts';
 import {
 	fetchDynamicDashboardPage,
 	type DynamicDashboardMetric,
@@ -333,37 +352,89 @@ 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';
+
+// 配置化维度能力(capability 是唯一能力来源,不按 KPI 名/模块猜测)
+const capMap = ref<Record<string, KpiDimensionCapability>>({});
+let capLoadToken = 0;
+function dimensionCapOf(metricCode?: string | null): KpiDimensionCapability | null {
+	return (metricCode && capMap.value[metricCode]) || null;
 }
-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 isDimensionManaged(metric: any): boolean {
+	return dimensionCapOf(metric?.metricCode)?.supported === true;
+}
+// 收集页面上 L1+L2 指标码,一次性并发拉 capability,缓存至下次刷新/激活/运行
+async function loadCapabilities() {
+	const codes = new Set<string>();
+	for (const section of page.value?.sections || []) {
+		for (const widget of section.widgets || []) {
+			for (const m of widget.metrics || []) {
+				if ((m.metricLevel === 1 || m.metricLevel === 2) && m.metricCode) codes.add(m.metricCode);
+			}
+		}
+	}
+	if (codes.size === 0) {
+		capMap.value = {};
+		return;
 	}
+	const token = ++capLoadToken;
+	const list = [...codes];
+	const results = await Promise.all(
+		list.map((code) =>
+			getDimensionCapability(code, moduleCode.value)
+				.then((res) => unwrapDim<KpiDimensionCapability>(res))
+				.catch(() => null)
+		)
+	);
+	if (token !== capLoadToken) return; // 丢弃过期结果,防止请求竞态覆盖
+	const next: Record<string, KpiDimensionCapability> = {};
+	list.forEach((code, i) => {
+		const cap = results[i];
+		if (cap) next[code] = cap;
+	});
+	capMap.value = next;
+}
+
+// —— 每指标 filterMode 展示(对任意受管码通用,不用模块级 scope)——
+function dimFilterLabel(m: any) {
+	if (!isDimensionManaged(m) || !m?.filterMode) return '';
+	return FILTER_MODE_LABEL[m.filterMode] || '';
 }
 function dimFilterClass(m: any) {
-	if (m?.filterMode === 'configurable_dimension') return 'is-dim-ok';
-	if (m?.filterMode === 'version_mismatch') return 'is-dim-warn';
+	if (m?.filterMode === FILTER_MODE.CONFIGURABLE_DIMENSION) return 'is-dim-ok';
+	if (m?.filterMode === FILTER_MODE.VERSION_MISMATCH || m?.filterMode === FILTER_MODE.FAILED) return 'is-dim-warn';
 	return 'is-dim-muted';
 }
-function openOrderDrilldown(widget: any) {
-	orderDrilldownFilterMode.value = widget?.metrics?.[0]?.filterMode ?? null;
-	orderDrilldownVisible.value = true;
+
+// —— S7_L1_001 保留专用抽屉(低风险回归,不迁通用)——
+const orderDrilldownVisible = ref(false);
+const orderDrilldownFilterMode = ref<string | null>(null);
+// —— 其余受管码走通用维度抽屉 ——
+const genericDrilldownVisible = ref(false);
+const genericDrilldownMetric = ref<any>(null);
+const genericDrilldownCap = computed<KpiDimensionCapability | null>(() => dimensionCapOf(genericDrilldownMetric.value?.metricCode));
+const genericDrilldownFilterMode = ref<string | null>(null);
+// 打开抽屉时把模块筛选中对应业务维值带入,保证卡片值与抽屉聚合同口径
+const genericInitialDimValue = computed<string | null>(() => {
+	const bizDim = businessDimensions(genericDrilldownCap.value?.supportedDimensions)[0];
+	const cardParam = bizDim ? DIM_TO_CARD_PARAM[bizDim] : '';
+	return cardParam ? (dashFilter.value?.[cardParam] ?? null) : null;
+});
+function firstMetricOf(widget: any) {
+	return widget?.metrics?.[0] || widget || null;
+}
+// 统一下钻入口:S7_L1_001→专用抽屉,其余受管码→通用抽屉
+function openDrilldown(widgetOrMetric: any) {
+	const m = firstMetricOf(widgetOrMetric);
+	if (!m || !isDimensionManaged(m)) return;
+	if (m.metricCode === 'S7_L1_001') {
+		orderDrilldownFilterMode.value = m.filterMode ?? null;
+		orderDrilldownVisible.value = true;
+		return;
+	}
+	genericDrilldownMetric.value = m;
+	genericDrilldownFilterMode.value = m.filterMode ?? null;
+	genericDrilldownVisible.value = true;
 }
 const scopeLabel = computed(() => {
 	const scope = page.value?.dataSource?.scope;
@@ -454,6 +525,8 @@ async function reload() {
 		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();
+		// 维度能力随页面数据加载(并发一次,缓存至下次刷新);失败不阻塞看板渲染
+		loadCapabilities().catch(() => {});
 		await nextTick();
 		renderCharts();
 	} finally {

+ 461 - 0
Web/src/views/aidop/kanban/components/KpiDimensionConfigPanel.vue

@@ -0,0 +1,461 @@
+<template>
+	<div v-if="!metricCode" class="dim-cfg-empty">请先在「指标管理」选择一个 KPI</div>
+	<div v-else v-loading="loading" class="dim-cfg">
+		<div class="dim-cfg__head">
+			<h3>{{ metricCode }} · 维度配置</h3>
+			<span class="dim-cfg__sub">配置化 DIMENSION_SQL,用于卡片筛选与下钻;与汇总口径解耦</span>
+		</div>
+
+		<!-- 当前维度配置概览 -->
+		<el-descriptions v-if="current" :column="4" border size="small" class="dim-cfg__overview">
+			<el-descriptions-item label="维度版本">v{{ current.dimensionConfigVersion }}</el-descriptions-item>
+			<el-descriptions-item label="绑定汇总版本">v{{ current.summaryConfigVersion }}</el-descriptions-item>
+			<el-descriptions-item label="当前汇总版本">v{{ activeSummaryVersion ?? '-' }}</el-descriptions-item>
+			<el-descriptions-item label="版本一致">
+				<el-tag :type="isCurrentBindingConsistent ? 'success' : 'danger'" size="small">
+					{{ isCurrentBindingConsistent ? '一致' : '不一致' }}
+				</el-tag>
+			</el-descriptions-item>
+			<el-descriptions-item label="数据源">{{ current.dataSourceCode || '-' }}</el-descriptions-item>
+			<el-descriptions-item label="聚合类型">{{ current.aggregationType }}</el-descriptions-item>
+			<el-descriptions-item label="支持维度">{{ currentDimsText }}</el-descriptions-item>
+			<el-descriptions-item label="发布状态">{{ current.publishStatus }}</el-descriptions-item>
+			<el-descriptions-item label="最后运行">
+				<el-tag :type="runTagType(capability?.lastRunStatus)" size="small">{{ capability?.lastRunStatus || '—' }}</el-tag>
+				<span class="dim-cfg__muted"> {{ capability?.lastRunAt || '' }}</span>
+			</el-descriptions-item>
+		</el-descriptions>
+		<el-alert v-else type="info" :closable="false" show-icon title="当前 KPI 尚未配置维度 SQL,可新建草稿后校验、试算、发布、激活。" class="dim-cfg__overview" />
+
+		<el-alert
+			v-if="current && !isCurrentBindingConsistent"
+			type="warning"
+			:closable="false"
+			show-icon
+			class="dim-cfg__warn"
+			title="当前维度配置绑定的汇总版本与生效汇总版本不一致,看板将标记 version_mismatch 且不回落。请复制新版本、重新绑定当前汇总后发布激活。"
+		/>
+
+		<!-- 版本列表 -->
+		<div class="dim-cfg__bar">
+			<span class="dim-cfg__bar-title">配置版本</span>
+			<el-button size="small" @click="load">刷新</el-button>
+			<el-button size="small" type="primary" plain @click="newDraft">新建草稿</el-button>
+			<el-button size="small" :loading="running" @click="doRun">运行一次</el-button>
+		</div>
+		<el-table :data="versions" size="small" border class="dim-cfg__versions">
+			<el-table-column prop="dimensionConfigVersion" label="版本" width="64" />
+			<el-table-column label="绑定汇总" width="90">
+				<template #default="{ row }">
+					<span :class="{ 'dim-cfg__badmatch': isMismatch(row) }">v{{ row.summaryConfigVersion }}</span>
+				</template>
+			</el-table-column>
+			<el-table-column prop="publishStatus" label="状态" width="96" />
+			<el-table-column label="生效" width="60">
+				<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success" size="small">生效</el-tag></template>
+			</el-table-column>
+			<el-table-column prop="dataSourceCode" label="数据源" width="110" />
+			<el-table-column prop="aggregationType" label="聚合" width="140" />
+			<el-table-column prop="createdAt" label="创建时间" width="150" />
+			<el-table-column label="操作" min-width="260">
+				<template #default="{ row }">
+					<el-button link size="small" @click="loadInto(row)">载入</el-button>
+					<el-button link size="small" @click="copyFrom(row)">复制新版本</el-button>
+					<el-button
+						v-if="row.publishStatus === CONFIG_STATUS.DRAFT"
+						link type="primary" size="small" @click="doPublish(row)"
+					>发布</el-button>
+					<el-button
+						v-if="row.publishStatus === CONFIG_STATUS.PUBLISHED && !row.isCurrent"
+						link type="warning" size="small"
+						:disabled="isMismatch(row)" :title="isMismatch(row) ? '绑定汇总版本不一致,禁止激活' : ''"
+						@click="doActivate(row)"
+					>激活</el-button>
+					<el-button
+						v-if="row.publishStatus !== CONFIG_STATUS.RETIRED"
+						link type="danger" size="small" @click="doRetire(row)"
+					>退役</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<!-- 草稿编辑表单(仅草稿可编辑;已发布/退役只读) -->
+		<div class="dim-cfg__form">
+			<div class="dim-cfg__form-head">
+				<span>{{ draft.id ? (isDraftEditable ? '编辑草稿 v' + draft.dimensionConfigVersion : '查看 v' + draft.dimensionConfigVersion + '(' + draft.publishStatus + ',只读)') : '新建草稿' }}</span>
+			</div>
+			<el-form :inline="true" size="small">
+				<el-form-item label="数据源">
+					<el-select v-model="draft.dataSourceCode" style="width: 200px" :disabled="!isDraftEditable">
+						<el-option v-for="ds in dataSources" :key="ds.code" :label="ds.name" :value="ds.code" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="聚合类型">
+					<el-select v-model="draft.aggregationType" style="width: 180px" :disabled="!isDraftEditable">
+						<el-option v-for="a in AGG_OPTIONS" :key="a" :label="a" :value="a" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="超时(秒)">
+					<el-input-number v-model="draft.timeoutSeconds" :min="1" :max="120" :disabled="!isDraftEditable" />
+				</el-form-item>
+				<el-form-item label="支持维度">
+					<el-select v-model="draft.supportedDimensions" multiple style="width: 320px" :disabled="!isDraftEditable">
+						<el-option v-for="d in DIM_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
+					</el-select>
+				</el-form-item>
+			</el-form>
+			<el-form :inline="true" size="small">
+				<el-form-item label="业务来源">
+					<el-input v-model="draft.businessSqlSource" style="width: 260px" :disabled="!isDraftEditable" placeholder="DIMENSION_SQL 业务口径来源" />
+				</el-form-item>
+				<el-form-item label="变更说明">
+					<el-input v-model="draft.changeRemark" style="width: 300px" :disabled="!isDraftEditable" placeholder="本次维度配置变更说明" />
+				</el-form-item>
+			</el-form>
+
+			<div class="dim-cfg__params">
+				可用参数:@tenant_id @factory_id @biz_date @ztid | 结果契约(列):value_date(必需) dimension_type(必需) dimension_code(必需)
+				dimension_name metric_value numerator denominator sum_value sample_count source_key | 只允许 SELECT/WITH
+			</div>
+			<SqlEditor ref="sqlEditorRef" v-model="draft.sqlScript" height="240px" :readonly="!isDraftEditable" />
+
+			<div class="dim-cfg__actions">
+				<el-button size="small" @click="doValidate">校验</el-button>
+				<el-button size="small" :loading="previewing" @click="doPreview">试算</el-button>
+				<el-button v-if="isDraftEditable" type="primary" size="small" :loading="saving" @click="saveDraft">保存草稿</el-button>
+			</div>
+
+			<div v-if="validation" class="dim-cfg__line" :class="validationOk ? 'ok' : 'err'">
+				校验:{{ validation.validationStatus || validation.status }}
+				<span v-if="validation.errorMessage">|{{ validation.errorCode }}: {{ validation.errorMessage }}</span>
+				<span v-if="validation.outputColumns?.length">|输出列:{{ validation.outputColumns.join(', ') }}</span>
+				<span v-if="validation.missingColumns?.length" class="dim-cfg__miss">|缺失列:{{ validation.missingColumns.join(', ') }}</span>
+			</div>
+			<div v-if="preview" class="dim-cfg__line" :class="preview.ok ? 'ok' : 'err'">
+				试算:{{ preview.resultStatus }} | 行数={{ preview.rowCount }} | 耗时={{ preview.durationMs }}ms
+				<span v-if="preview.errorMessage">|{{ preview.errorCode }}: {{ preview.errorMessage }}</span>
+				<div v-if="preview.outputColumns?.length" class="dim-cfg__muted">输出列:{{ preview.outputColumns.join(', ') }}</div>
+				<el-table v-if="preview.sampleRows?.length" :data="preview.sampleRows" size="small" border class="dim-cfg__preview-table">
+					<el-table-column v-for="col in previewColumns" :key="col" :prop="col" :label="col" min-width="110" show-overflow-tooltip />
+				</el-table>
+			</div>
+		</div>
+
+		<!-- 维度运行日志 -->
+		<div class="dim-cfg__bar">
+			<span class="dim-cfg__bar-title">维度运行日志</span>
+			<el-button size="small" @click="loadRunLogs">刷新</el-button>
+		</div>
+		<el-table :data="runLogs" size="small" border class="dim-cfg__runlog">
+			<el-table-column prop="batchId" label="批次" min-width="200" show-overflow-tooltip />
+			<el-table-column prop="dimensionConfigVersion" label="维度版本" width="90" />
+			<el-table-column prop="summaryConfigVersion" label="汇总版本" width="90" />
+			<el-table-column prop="valueDate" label="业务日期" width="150" />
+			<el-table-column label="状态" width="96">
+				<template #default="{ row }"><el-tag :type="runTagType(row.status)" size="small">{{ row.status }}</el-tag></template>
+			</el-table-column>
+			<el-table-column prop="rowCount" label="行数" width="80" align="right" />
+			<el-table-column prop="durationMs" label="耗时(ms)" width="100" align="right" />
+			<el-table-column prop="errorMessage" label="错误摘要" min-width="180" show-overflow-tooltip />
+		</el-table>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import SqlEditor from './SqlEditor.vue';
+import {
+	getDimensionConfigByMetric,
+	getDimensionCapability,
+	getDimensionDataSources,
+	createDimensionDraft,
+	updateDimensionDraft,
+	validateDimensionSql,
+	previewDimensionConfig,
+	publishDimensionConfig,
+	activateDimensionConfig,
+	retireDimensionConfig,
+	runDimension,
+	getDimensionRunLog,
+	type KpiDimensionConfig,
+	type KpiDimensionCapability,
+} from '../../api/kpiDimensionConfigApi';
+import { CONFIG_STATUS, DIM_FIELD_LABEL, DIM_FIELD, unwrapDim } from './kpiDimensionConsts';
+
+const props = defineProps<{ metricCode?: string; moduleCode?: string }>();
+
+const AGG_OPTIONS = ['DIRECT_VALUE', 'RATIO_OF_SUMS', 'AVERAGE_OF_SUMS', 'SUMMARY_ONLY'];
+const DIM_OPTIONS = Object.values(DIM_FIELD).map((value) => ({ value, label: DIM_FIELD_LABEL[value] || value }));
+
+const loading = ref(false);
+const saving = ref(false);
+const previewing = ref(false);
+const running = ref(false);
+const versions = ref<KpiDimensionConfig[]>([]);
+const capability = ref<KpiDimensionCapability | null>(null);
+const dataSources = ref<{ code: string; name: string }[]>([]);
+const runLogs = ref<any[]>([]);
+const validation = ref<any>(null);
+const preview = ref<any>(null);
+
+interface DraftForm {
+	id?: string | number;
+	dimensionConfigVersion?: number;
+	publishStatus?: string;
+	dataSourceCode?: string;
+	sqlScript: string;
+	aggregationType: string;
+	supportedDimensions: string[];
+	timeoutSeconds: number;
+	businessSqlSource?: string;
+	changeRemark?: string;
+}
+function emptyDraft(): DraftForm {
+	return { sqlScript: '', aggregationType: 'AVERAGE_OF_SUMS', supportedDimensions: [], timeoutSeconds: 60 };
+}
+const draft = ref<DraftForm>(emptyDraft());
+const sqlEditorRef = ref<any>(null);
+
+const current = computed<KpiDimensionConfig | null>(() => versions.value.find((v) => v.isCurrent) || null);
+// 生效汇总版本:能力接口回传的绑定汇总版本即当前生效汇总版本
+const activeSummaryVersion = computed<number | null>(() => capability.value?.summaryConfigVersion ?? null);
+const isCurrentBindingConsistent = computed(() => {
+	if (!current.value) return true;
+	if (activeSummaryVersion.value == null) return true;
+	return current.value.summaryConfigVersion === activeSummaryVersion.value;
+});
+function isMismatch(row: KpiDimensionConfig): boolean {
+	return activeSummaryVersion.value != null && row.summaryConfigVersion !== activeSummaryVersion.value;
+}
+const currentDimsText = computed(() => parseDims(current.value?.supportedDimensionsJson).map((d) => DIM_FIELD_LABEL[d] || d).join('、') || '-');
+const isDraftEditable = computed(() => !draft.value.id || draft.value.publishStatus === CONFIG_STATUS.DRAFT);
+const validationOk = computed(() => {
+	const s = validation.value?.validationStatus || validation.value?.status;
+	return s === 'OK' || s === 'PASSED' || s === 'VALID' || validation.value?.ok === true;
+});
+const previewColumns = computed<string[]>(() => {
+	const first = preview.value?.sampleRows?.[0];
+	return first ? Object.keys(first) : [];
+});
+
+function parseDims(json?: string | null): string[] {
+	if (!json) return [];
+	try {
+		const v = JSON.parse(json);
+		return Array.isArray(v) ? v : [];
+	} catch {
+		return [];
+	}
+}
+function runTagType(s?: string | null) {
+	if (s === 'SUCCESS') return 'success';
+	if (s === 'FAILED') return 'danger';
+	if (s === 'NO_DATA') return 'info';
+	return 'info';
+}
+
+async function load() {
+	if (!props.metricCode || !props.moduleCode) return;
+	loading.value = true;
+	try {
+		const [vRes, cRes, dsRes] = await Promise.all([
+			getDimensionConfigByMetric(props.metricCode, props.moduleCode),
+			getDimensionCapability(props.metricCode, props.moduleCode),
+			getDimensionDataSources(),
+		]);
+		const vList = unwrapDim<any>(vRes);
+		versions.value = Array.isArray(vList) ? vList : vList?.list || [];
+		capability.value = unwrapDim<KpiDimensionCapability>(cRes) || null;
+		const ds = unwrapDim<any>(dsRes);
+		const arr = Array.isArray(ds) ? ds : ds?.list || [];
+		dataSources.value = arr.map((x: any) => ({ code: x.code ?? x.dataSourceCode ?? x.value, name: x.name ?? x.dataSourceName ?? x.code ?? x.dataSourceCode }));
+		await loadRunLogs();
+	} catch (e: any) {
+		ElMessage.error('加载维度配置失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadRunLogs() {
+	if (!props.metricCode || !props.moduleCode) return;
+	try {
+		const res = unwrapDim<any>(await getDimensionRunLog(props.metricCode, { moduleCode: props.moduleCode, page: 1, pageSize: 20 }));
+		runLogs.value = Array.isArray(res) ? res : res?.list || res?.items || [];
+	} catch {
+		runLogs.value = [];
+	}
+}
+
+function newDraft() {
+	draft.value = emptyDraft();
+	validation.value = null;
+	preview.value = null;
+	sqlEditorRef.value?.refresh?.();
+}
+function loadInto(row: KpiDimensionConfig) {
+	draft.value = {
+		id: row.id,
+		dimensionConfigVersion: row.dimensionConfigVersion,
+		publishStatus: row.publishStatus,
+		dataSourceCode: row.dataSourceCode || undefined,
+		sqlScript: row.sqlScript || '',
+		aggregationType: row.aggregationType || 'AVERAGE_OF_SUMS',
+		supportedDimensions: parseDims(row.supportedDimensionsJson),
+		timeoutSeconds: row.timeoutSeconds || 60,
+		businessSqlSource: row.businessSqlSource || undefined,
+		changeRemark: row.changeRemark || undefined,
+	};
+	validation.value = null;
+	preview.value = null;
+	sqlEditorRef.value?.refresh?.();
+}
+function copyFrom(row: KpiDimensionConfig) {
+	loadInto(row);
+	// 去掉 id → 视为新草稿,后端分配新维度版本并重新绑定当前汇总
+	draft.value.id = undefined;
+	draft.value.dimensionConfigVersion = undefined;
+	draft.value.publishStatus = undefined;
+	ElMessage.info('已复制为新草稿,保存后将绑定当前汇总版本');
+}
+
+function buildUpsertPayload() {
+	return {
+		metricCode: props.metricCode,
+		moduleCode: props.moduleCode,
+		dataSourceCode: draft.value.dataSourceCode,
+		sqlScript: draft.value.sqlScript,
+		aggregationType: draft.value.aggregationType,
+		supportedDimensionsJson: JSON.stringify(draft.value.supportedDimensions || []),
+		timeoutSeconds: draft.value.timeoutSeconds,
+		businessSqlSource: draft.value.businessSqlSource,
+		changeRemark: draft.value.changeRemark,
+	};
+}
+
+async function doValidate() {
+	if (!draft.value.sqlScript?.trim()) return ElMessage.warning('请先填写 DIMENSION_SQL');
+	try {
+		validation.value = unwrapDim<any>(await validateDimensionSql(draft.value.sqlScript));
+	} catch (e: any) {
+		ElMessage.error('校验失败:' + (e?.response?.data?.message || e?.message || e));
+	}
+}
+async function doPreview() {
+	if (!draft.value.sqlScript?.trim()) return ElMessage.warning('请先填写 DIMENSION_SQL');
+	previewing.value = true;
+	try {
+		preview.value = unwrapDim<any>(
+			await previewDimensionConfig({
+				metricCode: props.metricCode,
+				moduleCode: props.moduleCode,
+				dataSourceCode: draft.value.dataSourceCode,
+				sqlScript: draft.value.sqlScript,
+				aggregationType: draft.value.aggregationType,
+				timeoutSeconds: draft.value.timeoutSeconds,
+			})
+		);
+	} catch (e: any) {
+		ElMessage.error('试算失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		previewing.value = false;
+	}
+}
+async function saveDraft() {
+	if (!draft.value.sqlScript?.trim()) return ElMessage.warning('请先填写 DIMENSION_SQL');
+	saving.value = true;
+	try {
+		if (draft.value.id) await updateDimensionDraft(draft.value.id, buildUpsertPayload());
+		else await createDimensionDraft(buildUpsertPayload());
+		ElMessage.success('草稿已保存');
+		await load();
+	} catch (e: any) {
+		ElMessage.error('保存失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		saving.value = false;
+	}
+}
+async function doPublish(row: KpiDimensionConfig) {
+	try {
+		await publishDimensionConfig(row.id);
+		ElMessage.success('已发布');
+		await load();
+	} catch (e: any) {
+		ElMessage.error('发布失败:' + (e?.response?.data?.message || e?.message || e));
+	}
+}
+async function doActivate(row: KpiDimensionConfig) {
+	if (isMismatch(row)) return ElMessage.warning('绑定汇总版本不一致,禁止激活');
+	try {
+		await activateDimensionConfig(row.id);
+		ElMessage.success('已激活');
+		await load();
+	} catch (e: any) {
+		ElMessage.error('激活失败:' + (e?.response?.data?.message || e?.message || e));
+	}
+}
+async function doRetire(row: KpiDimensionConfig) {
+	try {
+		await ElMessageBox.confirm('退役后该版本不可再激活,确认?', '提示', { type: 'warning' });
+	} catch {
+		return;
+	}
+	try {
+		await retireDimensionConfig(row.id);
+		ElMessage.success('已退役');
+		await load();
+	} catch (e: any) {
+		ElMessage.error('退役失败:' + (e?.response?.data?.message || e?.message || e));
+	}
+}
+async function doRun() {
+	if (!props.metricCode || !props.moduleCode) return;
+	running.value = true;
+	try {
+		const res = unwrapDim<any>(await runDimension(props.metricCode, props.moduleCode));
+		ElMessage.success('已运行:' + (res?.status || 'OK') + (res?.rowCount != null ? `,行数 ${res.rowCount}` : ''));
+		await load();
+	} catch (e: any) {
+		ElMessage.error('运行失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		running.value = false;
+	}
+}
+
+watch(
+	() => [props.metricCode, props.moduleCode],
+	() => {
+		versions.value = [];
+		capability.value = null;
+		runLogs.value = [];
+		draft.value = emptyDraft();
+		validation.value = null;
+		preview.value = null;
+		load();
+	},
+	{ immediate: true }
+);
+</script>
+
+<style scoped>
+.dim-cfg-empty { padding: 40px; text-align: center; color: var(--el-text-color-secondary); }
+.dim-cfg { display: flex; flex-direction: column; gap: 12px; }
+.dim-cfg__head h3 { margin: 0; font-size: 15px; }
+.dim-cfg__sub { font-size: 12px; color: var(--el-text-color-secondary); }
+.dim-cfg__overview { margin-top: 4px; }
+.dim-cfg__warn { margin: 4px 0; }
+.dim-cfg__bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
+.dim-cfg__bar-title { font-weight: 600; font-size: 14px; margin-right: 4px; }
+.dim-cfg__badmatch { color: var(--el-color-danger); font-weight: 600; }
+.dim-cfg__form { border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 12px; }
+.dim-cfg__form-head { font-weight: 600; margin-bottom: 8px; }
+.dim-cfg__params { font-size: 12px; color: var(--el-text-color-secondary); margin: 6px 0; line-height: 1.6; }
+.dim-cfg__actions { display: flex; gap: 8px; margin-top: 8px; }
+.dim-cfg__line { font-size: 12px; margin-top: 6px; padding: 6px 8px; border-radius: 4px; }
+.dim-cfg__line.ok { background: var(--el-color-success-light-9); color: var(--el-color-success); }
+.dim-cfg__line.err { background: var(--el-color-danger-light-9); color: var(--el-color-danger); }
+.dim-cfg__miss { color: var(--el-color-danger); }
+.dim-cfg__muted { color: var(--el-text-color-secondary); }
+.dim-cfg__preview-table { margin-top: 6px; }
+</style>

+ 267 - 0
Web/src/views/aidop/kanban/components/KpiDimensionDrawer.vue

@@ -0,0 +1,267 @@
+<template>
+	<el-drawer v-model="innerVisible" :size="860" direction="rtl" @closed="onClosed">
+		<template #header>
+			<div class="drawer-head">
+				<span class="drawer-title">{{ metricName || metricCode }} · 维度明细</span>
+				<span class="drawer-sub">{{ metricCode }} 配置化维度下钻</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>
+
+			<!-- 通用 capability 驱动筛选 -->
+			<KpiDimensionFilterPanel
+				v-if="capState === 'configured'"
+				v-model="filterValue"
+				:metric-code="metricCode"
+				:metric-name="metricName"
+				:capability="capability"
+				:loading="loading"
+				@search="onSearch"
+				@reset="onReset"
+			/>
+
+			<!-- 明细表:动态列 -->
+			<el-table
+				v-if="capState === 'configured' && rows.length > 0"
+				:data="rows"
+				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="dimCodeLabel" min-width="160" show-overflow-tooltip />
+				<el-table-column v-if="colFlags.dimensionName" prop="dimensionName" label="维度名称" min-width="140" show-overflow-tooltip />
+				<el-table-column prop="metricValue" label="指标值" width="120" sortable="custom" align="right">
+					<template #default="{ row }">{{ fmt(row.metricValue) }}</template>
+				</el-table-column>
+				<el-table-column v-if="colFlags.numerator" prop="numerator" label="分子" width="100" align="right">
+					<template #default="{ row }">{{ fmt(row.numerator) }}</template>
+				</el-table-column>
+				<el-table-column v-if="colFlags.denominator" prop="denominator" label="分母" width="100" align="right">
+					<template #default="{ row }">{{ fmt(row.denominator) }}</template>
+				</el-table-column>
+				<el-table-column v-if="colFlags.sumValue" prop="sumValue" label="累计值" width="110" align="right">
+					<template #default="{ row }">{{ fmt(row.sumValue) }}</template>
+				</el-table-column>
+				<el-table-column v-if="colFlags.sampleCount" 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 === 'not_configured'" description="尚未配置维度筛选" />
+			<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 {
+	getDimensionCapability,
+	getDimensionDetail,
+	type KpiDimensionCapability,
+	type KpiDimensionDetailResult,
+} from '/@/views/aidop/api/kpiDimensionConfigApi';
+import KpiDimensionFilterPanel from './KpiDimensionFilterPanel.vue';
+import { DIM_FIELD_LABEL, RUN_STATUS, FILTER_MODE, businessDimensions, unwrapDim, type KpiDimensionFilterValue } from './kpiDimensionConsts';
+
+const props = defineProps<{
+	modelValue: boolean;
+	metricCode: string;
+	metricName?: string;
+	unit?: string;
+	moduleCode?: string;
+	capability?: KpiDimensionCapability | null;
+	initialFilterMode?: string | null;
+	initialStartDate?: string | null;
+	initialEndDate?: string | null;
+	initialDimValue?: string | null;
+}>();
+const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'closed'): void }>();
+
+const moduleCode = computed(() => props.moduleCode || props.capability?.moduleCode || '');
+
+const innerVisible = computed({
+	get: () => props.modelValue,
+	set: (v: boolean) => emit('update:modelValue', v),
+});
+
+type CapState = 'configured' | 'pending' | 'not_configured' | 'version_mismatch' | 'failed';
+const loading = ref(false);
+const detail = ref<KpiDimensionDetailResult | null>(null);
+const capState = ref<CapState>('configured');
+const capability = ref<KpiDimensionCapability | null>(props.capability || null);
+const filterValue = ref<KpiDimensionFilterValue>({});
+const page = ref(1);
+const pageSize = ref(20);
+const sortField = ref('value_date');
+const sortOrder = ref('desc');
+
+const rows = computed(() => detail.value?.list || []);
+
+// 业务维标签(用作维度编码列标题)
+const bizDim = computed<string>(() => businessDimensions(capability.value?.supportedDimensions)[0] || '');
+const dimCodeLabel = computed(() => DIM_FIELD_LABEL[bizDim.value] || '维度编码');
+
+// 动态列:仅当有行携带该字段非空时展示
+const colFlags = computed(() => {
+	const list = rows.value;
+	const anyNonNull = (k: keyof (typeof list)[number]) => list.some((r) => r[k] !== null && r[k] !== undefined);
+	return {
+		dimensionName: anyNonNull('dimensionName'),
+		numerator: anyNonNull('numerator'),
+		denominator: anyNonNull('denominator'),
+		sumValue: anyNonNull('sumValue'),
+		sampleCount: anyNonNull('sampleCount'),
+	};
+});
+
+function fmt(v: number | null | undefined) {
+	return v === null || v === undefined ? '—' : v;
+}
+const aggregateDisplay = computed(() => {
+	const v = detail.value?.aggregateValue;
+	if (v === null || v === undefined) return '—';
+	return props.unit ? `${v} ${props.unit}` : `${v}`;
+});
+
+async function loadDetail() {
+	if (!props.metricCode || !moduleCode.value) return;
+	loading.value = true;
+	try {
+		// 能力优先:未配置直接空态,不请求明细
+		const cap = unwrapDim<KpiDimensionCapability>(await getDimensionCapability(props.metricCode, moduleCode.value));
+		capability.value = cap || null;
+		if (!cap || cap.supported !== true) {
+			capState.value = 'pending';
+			detail.value = null;
+			return;
+		}
+		// 版本不匹配以卡片传入 filterMode 为准(详情端点只读当前版本,不作版本裁决,不回落)
+		if (props.initialFilterMode === FILTER_MODE.VERSION_MISMATCH) {
+			capState.value = 'version_mismatch';
+			detail.value = null;
+			return;
+		}
+		const res = unwrapDim<KpiDimensionDetailResult>(
+			await getDimensionDetail(props.metricCode, {
+				moduleCode: moduleCode.value,
+				startDate: filterValue.value.startDate || undefined,
+				endDate: filterValue.value.endDate || undefined,
+				// 业务维统一走 dimensionCode(每 KPI 单业务维)
+				dimensionCode: filterValue.value.dimValue || undefined,
+				sortField: sortField.value,
+				sortOrder: sortOrder.value,
+				page: page.value,
+				pageSize: pageSize.value,
+			})
+		);
+		if (!res || res.status === RUN_STATUS.NOT_CONFIGURED) {
+			capState.value = 'not_configured';
+			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 onSearch(v: KpiDimensionFilterValue) {
+	filterValue.value = v;
+	page.value = 1;
+	loadDetail();
+}
+function onReset() {
+	filterValue.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) {
+			capability.value = props.capability || null;
+			filterValue.value = {
+				startDate: props.initialStartDate || undefined,
+				endDate: props.initialEndDate || undefined,
+				dimValue: props.initialDimValue || undefined,
+			};
+			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-table { margin-top: 4px; }
+.dim-pager { margin-top: 8px; justify-content: flex-end; }
+</style>

+ 136 - 0
Web/src/views/aidop/kanban/components/KpiDimensionFilterPanel.vue

@@ -0,0 +1,136 @@
+<template>
+	<el-form v-if="hasAnyField" :inline="true" class="kpi-dim-filter" @submit.prevent>
+		<!-- 日期维(value_date) -->
+		<el-form-item v-if="showDate" label="日期">
+			<el-date-picker
+				v-model="dateRange"
+				type="daterange"
+				value-format="YYYY-MM-DD"
+				start-placeholder="开始日期"
+				end-placeholder="结束日期"
+				:clearable="true"
+				unlink-panels
+			/>
+		</el-form-item>
+
+		<!-- 月份维(biz_month) -->
+		<el-form-item v-if="showMonth" label="月份">
+			<el-date-picker
+				v-model="monthRange"
+				type="monthrange"
+				value-format="YYYY-MM"
+				start-placeholder="开始月份"
+				end-placeholder="结束月份"
+				:clearable="true"
+				unlink-panels
+			/>
+		</el-form-item>
+
+		<!-- 业务维(每 KPI 声明的单个业务维;统一映射到 dimensionCode) -->
+		<el-form-item v-if="bizDim" :label="bizDimLabel">
+			<el-input
+				v-model.trim="dimValue"
+				:placeholder="bizDimLabel + '(精确匹配)'"
+				clearable
+				style="width: 200px"
+			/>
+		</el-form-item>
+
+		<el-form-item>
+			<el-button type="primary" :loading="loading" @click="onSearch">查询</el-button>
+			<el-button :disabled="loading" @click="onReset">重置</el-button>
+		</el-form-item>
+	</el-form>
+	<div v-else class="kpi-dim-filter-empty">当前 KPI 无可用筛选维度</div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import type { KpiDimensionCapability } from '../../api/kpiDimensionConfigApi';
+import { DIM_FIELD, DIM_FIELD_LABEL, businessDimensions, type KpiDimensionFilterValue } from './kpiDimensionConsts';
+
+const props = defineProps<{
+	metricCode: string;
+	metricName?: string;
+	capability?: KpiDimensionCapability | null;
+	modelValue: KpiDimensionFilterValue;
+	loading?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: KpiDimensionFilterValue): void;
+	(e: 'search', v: KpiDimensionFilterValue): void;
+	(e: 'reset'): void;
+}>();
+
+const supported = computed<string[]>(() => props.capability?.supportedDimensions || []);
+const showDate = computed(() => supported.value.includes(DIM_FIELD.VALUE_DATE));
+const showMonth = computed(() => supported.value.includes(DIM_FIELD.BIZ_MONTH));
+// 每 KPI 恰一个业务维;取第一个非时间维
+const bizDim = computed<string>(() => businessDimensions(supported.value)[0] || '');
+const bizDimLabel = computed(() => DIM_FIELD_LABEL[bizDim.value] || bizDim.value);
+const hasAnyField = computed(() => showDate.value || showMonth.value || !!bizDim.value);
+
+// 本地受控状态(避免直接改 props)
+const dateRange = ref<[string, string] | null>(rangeFrom(props.modelValue.startDate, props.modelValue.endDate));
+const monthRange = ref<[string, string] | null>(rangeFrom(props.modelValue.bizMonthStart, props.modelValue.bizMonthEnd));
+const dimValue = ref<string>(props.modelValue.dimValue || '');
+
+function rangeFrom(a?: string, b?: string): [string, string] | null {
+	return a && b ? [a, b] : null;
+}
+
+// 外部 modelValue 变化时同步(如打开抽屉带入初始筛选)
+watch(
+	() => props.modelValue,
+	(v) => {
+		dateRange.value = rangeFrom(v.startDate, v.endDate);
+		monthRange.value = rangeFrom(v.bizMonthStart, v.bizMonthEnd);
+		dimValue.value = v.dimValue || '';
+	},
+	{ deep: true }
+);
+
+function collect(): KpiDimensionFilterValue {
+	const out: KpiDimensionFilterValue = {};
+	if (showDate.value && dateRange.value && dateRange.value[0] && dateRange.value[1]) {
+		out.startDate = dateRange.value[0];
+		out.endDate = dateRange.value[1];
+	}
+	if (showMonth.value && monthRange.value && monthRange.value[0] && monthRange.value[1]) {
+		out.bizMonthStart = monthRange.value[0];
+		out.bizMonthEnd = monthRange.value[1];
+	}
+	const v = (dimValue.value || '').trim();
+	if (bizDim.value && v) out.dimValue = v;
+	return out;
+}
+
+function onSearch() {
+	const v = collect();
+	emit('update:modelValue', v);
+	emit('search', v);
+}
+
+function onReset() {
+	dateRange.value = null;
+	monthRange.value = null;
+	dimValue.value = '';
+	emit('update:modelValue', {});
+	emit('reset');
+}
+</script>
+
+<style scoped>
+.kpi-dim-filter {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 4px 0;
+}
+.kpi-dim-filter-empty {
+	color: var(--el-text-color-secondary);
+	font-size: 13px;
+	padding: 4px 0;
+}
+</style>

+ 130 - 0
Web/src/views/aidop/kanban/components/kpiDimensionConsts.ts

@@ -0,0 +1,130 @@
+/**
+ * KPI 维度前端通用常量:维度字段、状态、标签映射。
+ * 统一来源,禁止在各组件重复魔法字符串。取值以后端 capability / detail 真实契约为准。
+ */
+
+/** 通用维度筛选值(抽屉/卡片共用;业务维统一落 dimValue→dimensionCode) */
+export interface KpiDimensionFilterValue {
+	startDate?: string;
+	endDate?: string;
+	bizMonthStart?: string;
+	bizMonthEnd?: string;
+	dimValue?: string;
+}
+
+/** 后端已声明的维度字段标识(capability.supportedDimensions 取值域) */
+export const DIM_FIELD = {
+	VALUE_DATE: 'value_date',
+	BIZ_MONTH: 'biz_month',
+	MATERIAL_CODE: 'material_code',
+	WORK_ORDER_NO: 'work_order_no',
+	CATEGORY_CODE: 'category_code',
+	ORDER_NO: 'order_no',
+	PRODUCT_CODE: 'product_code',
+	WAREHOUSE_CODE: 'warehouse_code',
+} as const;
+
+/** 维度字段中文标签 */
+export const DIM_FIELD_LABEL: Record<string, string> = {
+	value_date: '日期',
+	biz_month: '月份',
+	material_code: '物料编码',
+	work_order_no: '工单号',
+	category_code: '品类编码',
+	order_no: '订单号',
+	product_code: '产品编码',
+	warehouse_code: '仓库编码',
+};
+
+/** 明细行字段中文标签(表格动态列用) */
+export const DETAIL_COLUMN_LABEL: Record<string, string> = {
+	valueDate: '日期',
+	dimensionType: '维度类型',
+	dimensionCode: '维度编码',
+	dimensionName: '维度名称',
+	metricValue: '指标值',
+	numerator: '分子',
+	denominator: '分母',
+	sumValue: '累计值',
+	sampleCount: '样本数',
+	sourceKey: '业务键',
+	batchId: '批次',
+};
+
+/** 时间维度(value_date / biz_month)——用于区分“日期/月份筛选”与“业务维筛选” */
+export const TIME_DIMENSIONS: string[] = [DIM_FIELD.VALUE_DATE, DIM_FIELD.BIZ_MONTH];
+
+/**
+ * 业务维 → dashboard-page 卡片级筛选参数名。
+ * detail 抽屉统一走 dimensionCode;卡片值筛选走 dashboard-page,仅以下业务维有对应参数。
+ * category_code 无卡片级参数(仅抽屉分解维),故不在此表。
+ */
+export const DIM_TO_CARD_PARAM: Record<string, string> = {
+	material_code: 'material',
+	work_order_no: 'workOrder',
+	order_no: 'orderNo',
+	product_code: 'product',
+	warehouse_code: 'warehouse',
+};
+
+/** 维度配置发布状态 */
+export const CONFIG_STATUS = {
+	DRAFT: 'DRAFT',
+	VALIDATED: 'VALIDATED',
+	PUBLISHED: 'PUBLISHED',
+	RETIRED: 'RETIRED',
+} as const;
+
+/** 维度能力状态(capability.capabilityStatus) */
+export const CAPABILITY_STATUS = {
+	PENDING: 'PENDING_DIMENSION_SQL',
+	CONFIGURED: 'CONFIGURED',
+	SUMMARY_ONLY: 'SUMMARY_ONLY',
+} as const;
+
+/** 运行 / 明细状态 */
+export const RUN_STATUS = {
+	SUCCESS: 'SUCCESS',
+	NO_DATA: 'NO_DATA',
+	FAILED: 'FAILED',
+	OK: 'OK',
+	NOT_CONFIGURED: 'NOT_CONFIGURED',
+	VERSION_MISMATCH: 'VERSION_MISMATCH',
+} as const;
+
+/** dashboard 每指标 filterMode(后端 KpiDimensionDashboardService 输出) */
+export const FILTER_MODE = {
+	SUMMARY_ONLY: 'summary_only',
+	CONFIGURABLE_DIMENSION: 'configurable_dimension',
+	DIMENSION_NO_DATA: 'dimension_no_data',
+	VERSION_MISMATCH: 'version_mismatch',
+	FILTER_NOT_CONFIGURED: 'filter_not_configured',
+	NOT_CONFIGURED: 'not_configured',
+	FAILED: 'failed',
+} as const;
+
+/** filterMode → 展示文案(每指标独立判断,不用模块级 scope) */
+export const FILTER_MODE_LABEL: Record<string, string> = {
+	summary_only: '模块汇总口径',
+	configurable_dimension: '已按筛选',
+	dimension_no_data: '当前筛选范围暂无数据',
+	version_mismatch: '配置版本不一致',
+	filter_not_configured: '尚未配置维度筛选',
+	not_configured: '尚未配置维度筛选',
+	failed: '维度计算失败',
+};
+
+/** 共享响应解包(service 拦截器返回整个 axios response) */
+export function unwrapDim<T = any>(res: any): T {
+	return (res?.data?.result ?? res?.data ?? res) as T;
+}
+
+/** 判断某维度字段是否时间维 */
+export function isTimeDimension(dim: string): boolean {
+	return TIME_DIMENSIONS.includes(dim);
+}
+
+/** 取某 KPI 的业务维(非时间维)列表 */
+export function businessDimensions(supported: string[] | undefined | null): string[] {
+	return (supported || []).filter((d) => !isTimeDimension(d));
+}

+ 4 - 0
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -364,6 +364,9 @@
 					<KpiRunLog ref="runLogRef" :metric-code="currentDetail.metricCode" />
 				</div>
 			</el-tab-pane>
+			<el-tab-pane label="维度配置" name="dimension">
+				<KpiDimensionConfigPanel :metric-code="currentDetail?.metricCode" :module-code="currentDetail?.moduleCode" />
+			</el-tab-pane>
 		</el-tabs>
 	</div>
 </template>
@@ -380,6 +383,7 @@ import FormulaEditor from './components/FormulaEditor.vue';
 import SqlEditor from './components/SqlEditor.vue';
 import KpiBusinessInput from './components/KpiBusinessInput.vue';
 import KpiRunLog from './components/KpiRunLog.vue';
+import KpiDimensionConfigPanel from './components/KpiDimensionConfigPanel.vue';
 import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
 
 const router = useRouter();