Przeglądaj źródła

feat(s8): add order execution overview shell

YY968XX 1 tydzień temu
rodzic
commit
ac04f20b54

+ 42 - 0
Web/src/utils/aidopMenuDisplay.ts

@@ -59,6 +59,21 @@ const SMART_OPS_CHILDREN: Array<{ path: string; title: string; component: string
 	{ path: '/aidop/smart-ops/s9', title: 'S9运营指标看板', component: '/aidop/kanban/s9', name: 'aidopSmartOpsS9' },
 ];
 
+/**
+ * S8 异常监控大屏下的可见子菜单兜底(visible,非 hidden)。
+ * 用于 SysMenuSeedData 已新增但库尚未刷新的过渡期,避免侧栏不显示「订单执行档案」。
+ * 数据库刷新后由 upsertMenu 合并 component/meta,行为幂等。
+ */
+const S8_MONITORING_VISIBLE_ROUTES: Array<{ path: string; title: string; component: string; name: string; icon: string }> = [
+	{
+		path: '/aidop/s8/monitoring/order-execution',
+		title: '订单执行档案',
+		component: '/aidop/s8/monitoring/SoOrderExecutionDashboardPage',
+		name: 'aidopS8OrderExecution',
+		icon: 'ele-Tickets',
+	},
+];
+
 /** S8 配置中心中的隐藏详情页;库中若缺少授权或未同步新种子,前端补齐后可避免点卡片进入 404 */
 const S8_CONFIG_HIDDEN_ROUTES: Array<{ path: string; title: string; component: string; name: string }> = [
 	{
@@ -170,6 +185,32 @@ function patchAidopCustomMenusIfMissing(routes: AMenu[] | undefined): void {
 	}
 }
 
+/** 为 S8 异常监控目录补可见子路由(如「订单执行档案」),兼容库未刷新到最新菜单的过渡期。 */
+function patchS8MonitoringVisibleRoutesIfMissing(routes: AMenu[] | undefined): void {
+	if (!routes?.length) return;
+	const aidopRoot = routes.find((x) => x.path === '/aidop' || x.name === 'aidopRoot');
+	if (!aidopRoot?.children?.length) return;
+
+	const s8Root = aidopRoot.children.find(
+		(x: AMenu) => x.path === '/aidop/s8' || x.name === 'aidopDirS8' || x.name === 'aidopDirM09',
+	);
+	if (!s8Root?.children?.length) return;
+
+	const monitoringDir = s8Root.children.find(
+		(x: AMenu) => x.path === '/aidop/s8/monitoring' || x.name === 'aidopS8MonitoringDir',
+	);
+	if (!monitoringDir) return;
+
+	for (const route of S8_MONITORING_VISIBLE_ROUTES) {
+		upsertMenu(monitoringDir, {
+			path: route.path,
+			name: route.name,
+			component: route.component,
+			meta: { title: route.title, icon: route.icon },
+		});
+	}
+}
+
 /** 为 S8 配置中心补隐藏子路由,兼容数据库未刷新到最新菜单/角色授权时的直达 404 */
 function patchS8ConfigHiddenRoutesIfMissing(routes: AMenu[] | undefined): void {
 	if (!routes?.length) return;
@@ -225,6 +266,7 @@ function patchS8ConfigRouteComponents(nodes: AMenu[] | undefined): void {
 export function patchAidopMenuTitles(routes: any[] | undefined): void {
 	if (!routes?.length) return;
 	patchAidopCustomMenusIfMissing(routes as AMenu[]);
+	patchS8MonitoringVisibleRoutesIfMissing(routes as AMenu[]);
 	patchS8ConfigHiddenRoutesIfMissing(routes as AMenu[]);
 	patchSmartOpsRouteComponents(routes as AMenu[]);
 	patchS8ConfigRouteComponents(routes as AMenu[]);

+ 239 - 0
Web/src/views/aidop/s8/monitoring/SoOrderExecutionDashboardPage.vue

@@ -0,0 +1,239 @@
+<script setup lang="ts" name="aidopS8OrderExecution">
+// ORDER-FLOW-OVERVIEW-SHELL-1:SO 订单执行档案总览页壳。
+// 仅本轮:4 Tab + KPI 三卡 + 客户分组列表;不接后端、不做链路全景、不做筛选条。
+import { computed, onMounted, ref } from 'vue';
+import { useOrderExecutionStore } from '/@/stores/orderExecution';
+import OrderExecutionKpiCards from './components/order-execution/OrderExecutionKpiCards.vue';
+import OrderExecutionCustomerGroups from './components/order-execution/OrderExecutionCustomerGroups.vue';
+
+const store = useOrderExecutionStore();
+
+onMounted(() => {
+	store.loadFixture();
+});
+
+const customers = computed(() => store.customerGroups);
+const orders = computed(() => store.allOrders);
+const kpi = computed(() => store.kpiAverages);
+const observedAt = computed(() => store.currentObservationTime);
+const ready = computed(() => store.initialized && orders.value.length > 0);
+const orderCount = computed(() => orders.value.length);
+const customerCount = computed(() => customers.value.length);
+
+const activeTab = ref<'overview' | 'execution' | 'source-dept' | 'handle-dept'>('overview');
+</script>
+
+<template>
+	<div class="oe-dashboard">
+		<header class="oe-dashboard__header">
+			<div class="oe-dashboard__title">
+				<h1 class="oe-dashboard__title-main">SO 订单执行档案总览</h1>
+				<p class="oe-dashboard__title-sub">基于订单全流程维度的客户订单执行监控</p>
+			</div>
+			<div class="oe-dashboard__observation">
+				<span class="oe-dashboard__observation-label">当前观测时间</span>
+				<span class="oe-dashboard__observation-value">{{ observedAt }}</span>
+			</div>
+		</header>
+
+		<el-tabs v-model="activeTab" class="oe-dashboard__tabs">
+			<el-tab-pane label="订单/客户总览" name="overview">
+				<section class="oe-dashboard__section">
+					<OrderExecutionKpiCards :kpi="kpi" :ready="ready" />
+
+					<div class="oe-dashboard__counts">
+						<div class="oe-dashboard__count">
+							<span class="oe-dashboard__count-label">当前命中订单</span>
+							<span class="oe-dashboard__count-value">{{ ready ? orderCount : '--' }}</span>
+						</div>
+						<div class="oe-dashboard__count">
+							<span class="oe-dashboard__count-label">客户分组</span>
+							<span class="oe-dashboard__count-value">{{ ready ? customerCount : '--' }}</span>
+						</div>
+					</div>
+
+					<div class="oe-dashboard__group-title">
+						<span class="oe-dashboard__group-bar" />
+						<h2 class="oe-dashboard__group-text">客户分组</h2>
+					</div>
+
+					<OrderExecutionCustomerGroups :customers="customers" :preview-limit="3" />
+				</section>
+			</el-tab-pane>
+
+			<el-tab-pane label="执行流程环节" name="execution">
+				<div class="oe-dashboard__placeholder">后续迭代接入</div>
+			</el-tab-pane>
+			<el-tab-pane label="异常发生部门" name="source-dept">
+				<div class="oe-dashboard__placeholder">后续迭代接入</div>
+			</el-tab-pane>
+			<el-tab-pane label="处理部门" name="handle-dept">
+				<div class="oe-dashboard__placeholder">后续迭代接入</div>
+			</el-tab-pane>
+		</el-tabs>
+	</div>
+</template>
+
+<style scoped>
+.oe-dashboard {
+	--order-bg: #10131a;
+	--order-panel: rgba(25, 28, 34, 0.72);
+	--order-border: rgba(144, 144, 151, 0.18);
+	--order-accent: #7bd0ff;
+	--order-text-primary: #e1e2eb;
+	--order-text-secondary: #c6c6cd;
+	--order-text-muted: #909097;
+
+	min-height: calc(100vh - 108px);
+	padding: 18px 22px 28px;
+	box-sizing: border-box;
+	color: var(--order-text-primary);
+	background:
+		radial-gradient(circle at top left, rgba(123, 208, 255, 0.08), transparent 30%),
+		radial-gradient(circle at bottom right, rgba(109, 224, 57, 0.06), transparent 26%),
+		linear-gradient(180deg, #10131a 0%, #161b25 100%);
+	overflow-x: hidden;
+	overflow-y: auto;
+}
+
+.oe-dashboard__header {
+	display: flex;
+	align-items: flex-end;
+	justify-content: space-between;
+	gap: 16px;
+	margin-bottom: 14px;
+	padding: 12px 16px;
+	border-radius: 14px;
+	background: var(--order-panel);
+	border: 1px solid var(--order-border);
+}
+
+.oe-dashboard__title-main {
+	margin: 0 0 4px;
+	font-size: 22px;
+	font-weight: 700;
+	letter-spacing: 0.02em;
+}
+
+.oe-dashboard__title-sub {
+	margin: 0;
+	font-size: 12px;
+	color: var(--order-text-secondary);
+}
+
+.oe-dashboard__observation {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-end;
+	gap: 4px;
+}
+
+.oe-dashboard__observation-label {
+	font-size: 12px;
+	color: var(--order-text-muted);
+}
+
+.oe-dashboard__observation-value {
+	font-size: 16px;
+	font-weight: 700;
+	color: var(--order-accent);
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+}
+
+.oe-dashboard__tabs {
+	--el-tabs-header-height: 44px;
+}
+
+:deep(.oe-dashboard__tabs .el-tabs__item) {
+	color: var(--order-text-secondary);
+}
+
+:deep(.oe-dashboard__tabs .el-tabs__item.is-active) {
+	color: var(--order-accent);
+}
+
+:deep(.oe-dashboard__tabs .el-tabs__active-bar) {
+	background-color: var(--order-accent);
+}
+
+:deep(.oe-dashboard__tabs .el-tabs__nav-wrap::after) {
+	background-color: var(--order-border);
+}
+
+.oe-dashboard__section {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+	padding: 4px 0 12px;
+}
+
+.oe-dashboard__counts {
+	display: flex;
+	gap: 24px;
+	padding: 10px 16px;
+	border-radius: 12px;
+	background: var(--order-panel);
+	border: 1px solid var(--order-border);
+}
+
+.oe-dashboard__count {
+	display: flex;
+	align-items: baseline;
+	gap: 8px;
+}
+
+.oe-dashboard__count-label {
+	font-size: 12px;
+	color: var(--order-text-secondary);
+}
+
+.oe-dashboard__count-value {
+	font-size: 20px;
+	font-weight: 700;
+	color: var(--order-accent);
+}
+
+.oe-dashboard__group-title {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	margin-top: 4px;
+}
+
+.oe-dashboard__group-bar {
+	width: 5px;
+	height: 18px;
+	border-radius: 999px;
+	background: var(--order-accent);
+}
+
+.oe-dashboard__group-text {
+	margin: 0;
+	font-size: 16px;
+	font-weight: 700;
+}
+
+.oe-dashboard__placeholder {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	min-height: 240px;
+	border-radius: 14px;
+	background: var(--order-panel);
+	border: 1px dashed var(--order-border);
+	color: var(--order-text-secondary);
+	font-size: 14px;
+	letter-spacing: 0.04em;
+}
+
+@media (max-width: 768px) {
+	.oe-dashboard__header {
+		flex-direction: column;
+		align-items: flex-start;
+	}
+
+	.oe-dashboard__observation {
+		align-items: flex-start;
+	}
+}
+</style>

+ 229 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionCustomerGroups.vue

@@ -0,0 +1,229 @@
+<script setup lang="ts" name="OrderExecutionCustomerGroups">
+import { computed } from 'vue';
+import type {
+	CustomerGroup,
+	SalesOrderExecution,
+} from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	customers: CustomerGroup[];
+	previewLimit?: number;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	previewLimit: 3,
+});
+
+const customerCardData = computed(() =>
+	props.customers.map((customer) => ({
+		id: customer.id,
+		name: customer.name,
+		type: customer.type,
+		orderCount: customer.orders.length,
+		previewOrders: customer.orders.slice(0, props.previewLimit),
+	})),
+);
+
+const workflowText = (status: SalesOrderExecution['workflowStatus']) =>
+	status === 'in_progress' ? '执行中' : '已完成';
+
+const nodeStatusClass = (status: SalesOrderExecution['nodeStatus']) =>
+	`oe-order-node--${status}`;
+</script>
+
+<template>
+	<div class="oe-customer-groups">
+		<div
+			v-for="customer in customerCardData"
+			:key="customer.id"
+			class="oe-customer-card"
+		>
+			<header class="oe-customer-card__head">
+				<div class="oe-customer-card__title">
+					<span class="oe-customer-card__name">{{ customer.name }}</span>
+					<span class="oe-customer-card__type" :class="`oe-customer-card__type--${customer.type.toLowerCase()}`">
+						{{ customer.type }}
+					</span>
+				</div>
+				<div class="oe-customer-card__count">
+					<span class="oe-customer-card__count-num">{{ customer.orderCount }}</span>
+					<span class="oe-customer-card__count-label">订单</span>
+				</div>
+			</header>
+
+			<ul class="oe-customer-card__orders">
+				<li
+					v-for="order in customer.previewOrders"
+					:key="order.soNo"
+					class="oe-order-row"
+				>
+					<div class="oe-order-row__top">
+						<span class="oe-order-row__so">{{ order.soNo }}</span>
+						<span class="oe-order-row__product">{{ order.productName }}</span>
+					</div>
+					<div class="oe-order-row__bottom">
+						<span class="oe-order-row__node" :class="nodeStatusClass(order.nodeStatus)">
+							{{ order.currentNode }}
+						</span>
+						<span class="oe-order-row__exception">
+							异常 {{ order.exceptionCount }}
+						</span>
+						<span class="oe-order-row__workflow">
+							{{ workflowText(order.workflowStatus) }}
+						</span>
+					</div>
+				</li>
+			</ul>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.oe-customer-groups {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+	gap: 14px;
+}
+
+.oe-customer-card {
+	display: flex;
+	flex-direction: column;
+	padding: 14px 16px;
+	border-radius: 14px;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+}
+
+.oe-customer-card__head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 10px;
+}
+
+.oe-customer-card__title {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.oe-customer-card__name {
+	font-size: 16px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-customer-card__type {
+	padding: 2px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+	line-height: 1.4;
+}
+
+.oe-customer-card__type--ka {
+	background: rgba(123, 208, 255, 0.16);
+	color: #7bd0ff;
+}
+
+.oe-customer-card__type--smb {
+	background: rgba(109, 224, 57, 0.14);
+	color: #88fd54;
+}
+
+.oe-customer-card__type--micro {
+	background: rgba(255, 193, 7, 0.14);
+	color: #ffc107;
+}
+
+.oe-customer-card__count {
+	display: flex;
+	align-items: baseline;
+	gap: 4px;
+	color: var(--order-text-secondary, #c6c6cd);
+}
+
+.oe-customer-card__count-num {
+	font-size: 22px;
+	font-weight: 700;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-customer-card__count-label {
+	font-size: 12px;
+}
+
+.oe-customer-card__orders {
+	margin: 0;
+	padding: 0;
+	list-style: none;
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.oe-order-row {
+	padding: 8px 10px;
+	border-radius: 10px;
+	background: rgba(15, 19, 26, 0.55);
+	border: 1px solid rgba(69, 70, 77, 0.28);
+}
+
+.oe-order-row__top {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	margin-bottom: 4px;
+}
+
+.oe-order-row__so {
+	font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
+	font-size: 12px;
+	color: #7bd0ff;
+}
+
+.oe-order-row__product {
+	font-size: 13px;
+	color: var(--order-text-primary, #e1e2eb);
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.oe-order-row__bottom {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	font-size: 11px;
+	color: var(--order-text-secondary, #c6c6cd);
+}
+
+.oe-order-row__node {
+	padding: 1px 8px;
+	border-radius: 999px;
+	font-size: 11px;
+}
+
+.oe-order-node--green {
+	background: rgba(109, 224, 57, 0.12);
+	color: #88fd54;
+}
+
+.oe-order-node--yellow {
+	background: rgba(255, 193, 7, 0.12);
+	color: #ffc107;
+}
+
+.oe-order-node--red {
+	background: rgba(255, 180, 171, 0.14);
+	color: #ffb4ab;
+}
+
+.oe-order-row__exception {
+	color: #ffc107;
+}
+
+.oe-order-row__workflow {
+	margin-left: auto;
+	color: var(--order-text-muted, #909097);
+}
+</style>

+ 84 - 0
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionKpiCards.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts" name="OrderExecutionKpiCards">
+import { computed } from 'vue';
+import type { OrderExecutionKpiAverages } from '/@/views/aidop/s8/monitoring/data/order-execution/types';
+
+interface Props {
+	kpi: OrderExecutionKpiAverages;
+	ready: boolean;
+}
+
+const props = defineProps<Props>();
+
+const fmt = (v: number) => (props.ready ? v.toFixed(1) : '--');
+
+const cards = computed(() => [
+	{ key: 'response', label: '平均响应时间', value: fmt(props.kpi.avgResponse), unit: '分钟', tone: 'accent' as const },
+	{ key: 'processing', label: '平均处理时间', value: fmt(props.kpi.avgProcessing), unit: '分钟', tone: 'warn' as const },
+	{ key: 'loss', label: '平均损失时间', value: fmt(props.kpi.avgLoss), unit: '分钟', tone: 'danger' as const },
+]);
+</script>
+
+<template>
+	<div class="oe-kpi-cards">
+		<div
+			v-for="card in cards"
+			:key="card.key"
+			class="oe-kpi-card"
+			:class="`oe-kpi-card--${card.tone}`"
+		>
+			<div class="oe-kpi-card__label">{{ card.label }}</div>
+			<div class="oe-kpi-card__value">
+				<span class="oe-kpi-card__num">{{ card.value }}</span>
+				<span class="oe-kpi-card__unit">{{ card.unit }}</span>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.oe-kpi-cards {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 16px;
+}
+
+.oe-kpi-card {
+	padding: 18px 22px;
+	border-radius: 14px;
+	background: var(--order-panel, rgba(25, 28, 34, 0.72));
+	border: 1px solid var(--order-border, rgba(144, 144, 151, 0.18));
+	box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.oe-kpi-card__label {
+	font-size: 13px;
+	color: var(--order-text-secondary, #c6c6cd);
+	margin-bottom: 8px;
+}
+
+.oe-kpi-card__value {
+	display: flex;
+	align-items: baseline;
+	gap: 6px;
+}
+
+.oe-kpi-card__num {
+	font-size: 30px;
+	font-weight: 700;
+	letter-spacing: 0.02em;
+	color: var(--order-text-primary, #e1e2eb);
+}
+
+.oe-kpi-card__unit {
+	font-size: 13px;
+	color: var(--order-text-muted, #909097);
+}
+
+.oe-kpi-card--accent .oe-kpi-card__num { color: #7bd0ff; }
+.oe-kpi-card--warn .oe-kpi-card__num { color: #ffc107; }
+.oe-kpi-card--danger .oe-kpi-card__num { color: #ffb4ab; }
+
+@media (max-width: 768px) {
+	.oe-kpi-cards { grid-template-columns: 1fr; }
+}
+</style>

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -775,6 +775,21 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             OrderNo = 13,
             Remark = "S8 供应异常大屏(占位)"
         };
+        // ORDER-FLOW-OVERVIEW-SHELL-1:SO 订单执行档案总览页壳(fixture/store 演示,未接后端)。
+        yield return new SysMenu
+        {
+            Id = s8MonitoringDirId + 5,
+            Pid = s8MonitoringDirId,
+            Title = "订单执行档案",
+            Path = "/aidop/s8/monitoring/order-execution",
+            Name = "aidopS8OrderExecution",
+            Component = "/aidop/s8/monitoring/SoOrderExecutionDashboardPage",
+            Icon = "ele-Tickets",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 14,
+            Remark = "SO 订单执行档案总览(fixture 演示)"
+        };
 
         // 可见业务页(「异常监控看板」复用自动生成的 1322000000027 菜单位)
         yield return new SysMenu