mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat(web): add M9 reporting pages (contract-sn, callback stats, project health)
This commit is contained in:
@@ -140,6 +140,24 @@ const routes = [
|
||||
component: () => import("../views/NotificationSettingsView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN"], title: "通知设置" },
|
||||
},
|
||||
{
|
||||
path: "reports/contract-sn",
|
||||
name: "contract-sn-report",
|
||||
component: () => import("../views/ContractSnReportView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "合同 SN 报表" },
|
||||
},
|
||||
{
|
||||
path: "reports/callback-stats",
|
||||
name: "callback-stats",
|
||||
component: () => import("../views/CallbackStatsView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "OPS"], title: "Callback 统计" },
|
||||
},
|
||||
{
|
||||
path: "reports/project-health",
|
||||
name: "project-health",
|
||||
component: () => import("../views/ProjectHealthView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN"], title: "项目健康度" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") },
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user