feat(web): add M9 reporting pages (contract-sn, callback stats, project health)

This commit is contained in:
2026-05-25 01:07:44 +08:00
parent 830ea626c9
commit 822774b711
4 changed files with 319 additions and 0 deletions
@@ -0,0 +1,90 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">Callback 统计</span>
<div class="actions">
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
</template>
<el-row :gutter="16" class="kpi-row">
<el-col :span="8">
<el-statistic title="总事件数" :value="stats.totalEvents" />
</el-col>
<el-col :span="8">
<el-statistic title="成功率" :value="stats.successRate" suffix="%" />
</el-col>
<el-col :span="8">
<el-statistic title="待处理" :value="stats.pendingCount" />
</el-col>
</el-row>
<el-table v-loading="loading" :data="distribution" stripe style="width: 100%">
<el-table-column prop="type" label="事件类型" min-width="200" />
<el-table-column prop="count" label="数量" width="120" align="right" />
</el-table>
</el-card>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { getCallbackStats } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const loading = ref(false);
const stats = reactive({ totalEvents: 0, successRate: 0, pendingCount: 0 });
const distribution = ref([]);
onMounted(async () => {
auth.restoreAxiosAuth();
await load();
});
async function load() {
loading.value = true;
try {
const { data } = await getCallbackStats();
const body = data && typeof data === "object" ? data : {};
stats.totalEvents = Number(body.totalEvents ?? 0);
stats.successRate = Number(body.successRate ?? 0);
stats.pendingCount = Number(body.pendingCount ?? 0);
distribution.value = Array.isArray(body.distribution) ? body.distribution : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载 Callback 统计失败"));
stats.totalEvents = 0;
stats.successRate = 0;
stats.pendingCount = 0;
distribution.value = [];
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.kpi-row {
margin-bottom: 20px;
}
</style>
@@ -0,0 +1,109 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">合同 SN 报表</span>
<div class="actions">
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
</template>
<el-row :gutter="16" class="kpi-row">
<el-col :span="6">
<el-statistic title="合同行项数" :value="kpi.totalLineItems" />
</el-col>
<el-col :span="6">
<el-statistic title="已发 SN" :value="kpi.totalIssued" />
</el-col>
<el-col :span="6">
<el-statistic title="已激活" :value="kpi.totalActivated" />
</el-col>
<el-col :span="6">
<el-statistic title="未发缺口" :value="kpi.totalGap" />
</el-col>
</el-row>
<el-table v-loading="loading" :data="rows" stripe style="width: 100%">
<el-table-column prop="contractTitle" label="合同名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" width="140" show-overflow-tooltip />
<el-table-column prop="lineItemName" label="行项名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="expectedCount" label="预期数量" width="110" align="right" />
<el-table-column prop="issuedCount" label="已发 SN" width="100" align="right" />
<el-table-column prop="activatedCount" label="已激活" width="100" align="right" />
<el-table-column label="未发缺口" width="110" align="right">
<template #default="{ row }">
<span :style="(row.gapCount ?? 0) > 0 ? 'color:var(--el-color-danger);font-weight:600' : ''">{{ row.gapCount ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="(row.gapCount ?? 0) > 0 ? 'warning' : 'success'" size="small">{{ (row.gapCount ?? 0) > 0 ? '缺额' : '正常' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { getContractSnReport } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const loading = ref(false);
const rows = ref([]);
const kpi = reactive({ totalLineItems: 0, totalIssued: 0, totalActivated: 0, totalGap: 0 });
onMounted(async () => {
auth.restoreAxiosAuth();
await load();
});
async function load() {
loading.value = true;
try {
const { data } = await getContractSnReport();
const list = Array.isArray(data) ? data : Array.isArray(data?.content) ? data.content : [];
rows.value = list;
kpi.totalLineItems = list.length;
kpi.totalIssued = list.reduce((s, r) => s + (Number(r.issuedCount) || 0), 0);
kpi.totalActivated = list.reduce((s, r) => s + (Number(r.activatedCount) || 0), 0);
kpi.totalGap = list.reduce((s, r) => s + (Number(r.gapCount) || 0), 0);
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载合同 SN 报表失败"));
rows.value = [];
kpi.totalLineItems = 0;
kpi.totalIssued = 0;
kpi.totalActivated = 0;
kpi.totalGap = 0;
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.kpi-row {
margin-bottom: 20px;
}
</style>
@@ -0,0 +1,102 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">项目健康度</span>
<div class="actions">
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="rows" stripe style="width: 100%">
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column label="交付率" width="120" align="right">
<template #default="{ row }">{{ formatPercent(row.deliveryRate) }}</template>
</el-table-column>
<el-table-column label="SN 发放率" width="120" align="right">
<template #default="{ row }">{{ formatPercent(row.snIssuedRate) }}</template>
</el-table-column>
<el-table-column label="激活率" width="120" align="right">
<template #default="{ row }">{{ formatPercent(row.activationRate) }}</template>
</el-table-column>
<el-table-column label="健康等级" width="130">
<template #default="{ row }">
<el-tag :type="healthTagType(row.healthLevel)" size="small">{{ healthLabel(row.healthLevel) }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { getProjectHealth } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const loading = ref(false);
const rows = ref([]);
onMounted(async () => {
auth.restoreAxiosAuth();
await load();
});
function formatPercent(v) {
if (v == null) return "—";
return Number(v).toFixed(1) + "%";
}
function healthTagType(v) {
const u = String(v ?? "").toUpperCase();
if (u === "GREEN") return "success";
if (u === "YELLOW") return "warning";
if (u === "RED") return "danger";
return "";
}
function healthLabel(v) {
const u = String(v ?? "").toUpperCase();
if (u === "GREEN") return "🟢 正常";
if (u === "YELLOW") return "🟡 关注";
if (u === "RED") return "🔴 风险";
return String(v ?? "—");
}
async function load() {
loading.value = true;
try {
const { data } = await getProjectHealth();
const list = Array.isArray(data) ? data : Array.isArray(data?.content) ? data.content : [];
rows.value = list;
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载项目健康度失败"));
rows.value = [];
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
</style>