## 新增服务模块 ### 1. ERP服务 (hzhub-erp) - 新增独立的ERP数据适配服务 - 支持SQL Server 2008 R2数据源 - 提供动态API配置管理系统 - 包含客户管理、销售数据等业务接口 ### 2. 系统服务 (hzhub-system) - 新增独立的系统管理服务 - 用户、角色、权限、部门、菜单管理 - 租户管理、操作日志、在线用户监控 - 工作流引擎(warm-flow)集成 - 企业微信审批同步功能 ### 3. API网关 (hzhub-gateway) - 新增Spring Cloud Gateway网关服务 - JWT认证、路由分发、限流熔断 - XSS防护、请求日志记录 - 统一入口端口8080 ## 后台管理功能增强 ### ERP动态API管理 - 新增动态API配置管理界面 - API测试、文档预览、统计监控 - 错误日志查看、缓存管理 - 从数据库表自动导入API配置 ### 系统管理增强 - 企业微信配置管理 - 企业微信审批同步配置 - 部门和用户管理优化 ## 员工门户功能完善 ### 业务页面 - 审批中心:工作流审批、待办任务 - CRM管理:客户关系管理 - 经销商管理:经销商数据展示 - 供应链管理:采购、库存、销售 - BI报表:数据可视化分析 - ERP数据探索:SQL Server数据查询 ### 个人中心 - 基本设置:个人信息管理 - 安全设置:密码修改、登录日志 - 锁屏功能:自动锁屏、手动锁屏 ### 其他功能 - 标签页管理:多标签页导航 - 页面缓存:keepAlive缓存机制 - 会话超时:自动检测并提示 ## 经销商门户 ### 页面路由 - 新增经销商管理页面路由 - AI聊天界面完善 ## 文档更新 - ERP API数据库初始化指南 - ERP API前端完整实现文档 - ERP API测试和验证指南 - Gateway路由迁移计划 - 项目配置文档更新 ## 部署脚本 - 统一启动/停止/重启脚本 - Docker Compose配置优化 - Nginx配置文件更新 ## 技术栈 - 后端: Spring Boot 3.5.8, Java 17 - 前端: Vue 3, TypeScript, Element Plus, Vben Admin - 工作流: warm-flow 1.8.2 - 网关: Spring Cloud Gateway - 数据库: MySQL 8.0, SQL Server 2008 R2 - 缓存: Redis 7 - 向量库: Weaviate 1.25.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
588 lines
18 KiB
Vue
588 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import type { VbenFormProps } from '@vben/common-ui';
|
||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
import {
|
||
Card,
|
||
Col,
|
||
Row,
|
||
Statistic,
|
||
Table,
|
||
Tabs,
|
||
TabPane,
|
||
DatePicker,
|
||
Space,
|
||
Tag,
|
||
Progress,
|
||
Empty,
|
||
Alert,
|
||
Divider,
|
||
Select,
|
||
Button,
|
||
Spin,
|
||
message,
|
||
Switch,
|
||
InputNumber,
|
||
Tooltip,
|
||
} from 'ant-design-vue';
|
||
import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
|
||
|
||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||
import { apiConfigList, apiConfigStats, apiConfigErrorLog } from '#/api/erp/api';
|
||
import type { ErpApiConfigVO } from '#/api/erp/api';
|
||
import ErrorDetailModal from './error-detail-modal.vue';
|
||
|
||
// 时间范围选择
|
||
const timeRange = ref<any[]>([]);
|
||
const startTime = computed(() => timeRange.value[0] || undefined);
|
||
const endTime = computed(() => timeRange.value[1] || undefined);
|
||
|
||
// API列表(用于选择查看统计)
|
||
const apiList = ref<ErpApiConfigVO[]>([]);
|
||
const selectedApiId = ref<number>();
|
||
|
||
// Loading状态
|
||
const loading = ref(false);
|
||
|
||
// 慢查询阈值(毫秒)
|
||
const slowQueryThreshold = ref<number>(1000);
|
||
|
||
// 自动刷新配置
|
||
const autoRefreshEnabled = ref(false);
|
||
const refreshInterval = ref<number>(30); // 刷新间隔(秒)
|
||
let refreshTimer: number | null = null;
|
||
|
||
// 错误详情弹窗
|
||
const errorDetailVisible = ref(false);
|
||
const selectedErrorRecord = ref<any>(null);
|
||
|
||
// 加载API列表
|
||
onMounted(async () => {
|
||
try {
|
||
const response = await apiConfigList({ pageNum: 1, pageSize: 100 });
|
||
apiList.value = response.rows || [];
|
||
if (apiList.value.length > 0) {
|
||
selectedApiId.value = apiList.value[0].apiId;
|
||
await loadStats();
|
||
}
|
||
} catch (error) {
|
||
console.error('加载API列表失败', error);
|
||
message.error('加载API列表失败');
|
||
}
|
||
});
|
||
|
||
// 统计数据
|
||
const statsData = ref({
|
||
totalCalls: 0,
|
||
successCalls: 0,
|
||
errorCalls: 0,
|
||
avgResponseTime: 0,
|
||
maxResponseTime: 0,
|
||
minResponseTime: 0,
|
||
errorRate: 0,
|
||
slowCalls: 0, // 慢查询数量
|
||
cacheEnabled: false, // 是否启用缓存
|
||
});
|
||
|
||
// 加载统计数据
|
||
async function loadStats() {
|
||
if (!selectedApiId.value) {
|
||
message.warning('请先选择API');
|
||
return;
|
||
}
|
||
|
||
loading.value = true;
|
||
try {
|
||
const stats = await apiConfigStats(
|
||
selectedApiId.value,
|
||
startTime.value,
|
||
endTime.value
|
||
);
|
||
|
||
console.log('统计数据:', stats);
|
||
|
||
// 获取选中的API配置,检查是否启用缓存
|
||
const selectedApi = apiList.value.find(api => api.apiId === selectedApiId.value);
|
||
|
||
statsData.value = {
|
||
totalCalls: stats.totalCalls || 0,
|
||
successCalls: stats.successCalls || 0,
|
||
errorCalls: stats.errorCalls || 0,
|
||
avgResponseTime: stats.avgResponseTime || 0,
|
||
maxResponseTime: stats.maxResponseTime || 0,
|
||
minResponseTime: stats.minResponseTime || 0,
|
||
errorRate: stats.errorRate || 0,
|
||
slowCalls: stats.slowCalls || 0, // TODO: 后端需要添加慢查询统计
|
||
cacheEnabled: selectedApi?.enableCache === 1,
|
||
};
|
||
|
||
// 同时加载错误日志
|
||
await loadErrorLog();
|
||
|
||
message.success('统计数据已更新');
|
||
} catch (error) {
|
||
console.error('加载统计数据失败', error);
|
||
message.error('加载统计数据失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// 错误日志表格配置
|
||
const errorLogColumns = [
|
||
{ title: '调用时间', dataIndex: 'callTime', width: 180 },
|
||
{ title: '响应时间', dataIndex: 'responseTime', width: 100, customRender: ({ text }) => `${text}ms` },
|
||
{ title: '客户端IP', dataIndex: 'clientIp', width: 150 },
|
||
{ title: '用户ID', dataIndex: 'userId', width: 120 },
|
||
{ title: '错误消息', dataIndex: 'errorMessage', ellipsis: true, width: 200 },
|
||
{ title: '操作', dataIndex: 'action', width: 80, fixed: 'right' as const },
|
||
];
|
||
|
||
const errorLogData = ref<any[]>([]);
|
||
|
||
// 加载错误日志
|
||
async function loadErrorLog() {
|
||
if (!selectedApiId.value) return;
|
||
|
||
try {
|
||
const logs = await apiConfigErrorLog(selectedApiId.value, 50);
|
||
console.log('错误日志:', logs);
|
||
errorLogData.value = logs || [];
|
||
} catch (error) {
|
||
console.error('加载错误日志失败', error);
|
||
errorLogData.value = [];
|
||
}
|
||
}
|
||
|
||
// API选择变化
|
||
function handleApiChange(apiId: number) {
|
||
selectedApiId.value = apiId;
|
||
}
|
||
|
||
// 查询按钮
|
||
function handleQuery() {
|
||
loadStats();
|
||
}
|
||
|
||
// 刷新按钮
|
||
async function handleRefresh() {
|
||
message.info('正在刷新统计数据...');
|
||
await loadStats();
|
||
}
|
||
|
||
// 重置时间范围
|
||
function handleResetTimeRange() {
|
||
timeRange.value = [];
|
||
loadStats();
|
||
}
|
||
|
||
// 查看错误详情
|
||
function viewErrorDetail(record: any) {
|
||
selectedErrorRecord.value = record;
|
||
errorDetailVisible.value = true;
|
||
}
|
||
|
||
// 关闭错误详情弹窗
|
||
function closeErrorDetail() {
|
||
errorDetailVisible.value = false;
|
||
selectedErrorRecord.value = null;
|
||
}
|
||
|
||
// 开启/关闭自动刷新
|
||
function toggleAutoRefresh(enabled: boolean) {
|
||
autoRefreshEnabled.value = enabled;
|
||
|
||
if (enabled) {
|
||
startAutoRefresh();
|
||
message.success(`已开启自动刷新,每${refreshInterval.value}秒刷新一次`);
|
||
} else {
|
||
stopAutoRefresh();
|
||
message.info('已关闭自动刷新');
|
||
}
|
||
}
|
||
|
||
// 启动自动刷新定时器
|
||
function startAutoRefresh() {
|
||
if (refreshTimer) {
|
||
clearInterval(refreshTimer);
|
||
}
|
||
|
||
refreshTimer = window.setInterval(() => {
|
||
if (selectedApiId.value && !loading.value) {
|
||
loadStats();
|
||
}
|
||
}, refreshInterval.value * 1000);
|
||
}
|
||
|
||
// 停止自动刷新
|
||
function stopAutoRefresh() {
|
||
if (refreshTimer) {
|
||
clearInterval(refreshTimer);
|
||
refreshTimer = null;
|
||
}
|
||
}
|
||
|
||
// 监听刷新间隔变化
|
||
watch(refreshInterval, (newInterval) => {
|
||
if (autoRefreshEnabled.value) {
|
||
stopAutoRefresh();
|
||
startAutoRefresh();
|
||
}
|
||
});
|
||
|
||
// 组件卸载时清理定时器
|
||
onMounted(() => {
|
||
// ... existing code
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopAutoRefresh();
|
||
});
|
||
|
||
// 当前Tab
|
||
const currentTab = ref('overview');
|
||
|
||
// 汇总统计(所有API)- 使用computed自动计算
|
||
const overallStats = computed(() => {
|
||
return {
|
||
totalApis: apiList.value.length,
|
||
enabledApis: apiList.value.filter(api => api.status === 1).length,
|
||
disabledApis: apiList.value.filter(api => api.status === 0).length,
|
||
cachedApis: apiList.value.filter(api => api.enableCache === 1).length,
|
||
};
|
||
});
|
||
|
||
// 计算性能健康度(0-100)
|
||
function calculateHealthScore(): number {
|
||
if (statsData.value.totalCalls === 0) return 100;
|
||
|
||
// 综合考虑错误率和响应时间
|
||
const errorScore = Math.max(0, 100 - statsData.value.errorRate * 10); // 错误率影响(每1%扣10分)
|
||
const timeScore = Math.max(0, 100 - (statsData.value.avgResponseTime / slowQueryThreshold.value) * 20); // 响应时间影响
|
||
|
||
return Math.round((errorScore + timeScore) / 2);
|
||
}
|
||
|
||
// 获取健康度颜色
|
||
function getHealthColor(): string {
|
||
const score = calculateHealthScore();
|
||
if (score >= 80) return '#3f8600'; // 优秀
|
||
if (score >= 60) return '#faad14'; // 一般
|
||
return '#cf1322'; // 差
|
||
}
|
||
|
||
// 获取健康度描述
|
||
function getHealthDescription(): string {
|
||
const score = calculateHealthScore();
|
||
if (score >= 80) return '性能优秀,系统运行正常';
|
||
if (score >= 60) return '性能一般,建议优化';
|
||
if (score >= 40) return '性能较差,需要优化';
|
||
return '性能很差,紧急优化';
|
||
}
|
||
|
||
// 计算响应时间评分(0-100)
|
||
function calculateResponseTimeScore(): number {
|
||
if (statsData.value.maxResponseTime === 0) return 100;
|
||
|
||
const avgRatio = statsData.value.avgResponseTime / slowQueryThreshold.value;
|
||
return Math.max(0, Math.min(100, 100 - avgRatio * 50));
|
||
}
|
||
|
||
// 获取响应时间颜色
|
||
function getResponseTimeColor(): string {
|
||
const score = calculateResponseTimeScore();
|
||
if (score >= 80) return '#3f8600';
|
||
if (score >= 60) return '#1890ff';
|
||
if (score >= 40) return '#faad14';
|
||
return '#cf1322';
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Page :auto-content-height="true">
|
||
<!-- 上方:汇总统计卡片 -->
|
||
<Card title="API概览" size="small" class="mb-4">
|
||
<Row :gutter="16">
|
||
<Col :span="6">
|
||
<Statistic title="API总数" :value="overallStats.totalApis" />
|
||
</Col>
|
||
<Col :span="6">
|
||
<Statistic title="启用API" :value="overallStats.enabledApis" :value-style="{ color: '#3f8600' }" />
|
||
</Col>
|
||
<Col :span="6">
|
||
<Statistic title="禁用API" :value="overallStats.disabledApis" :value-style="{ color: '#cf1322' }" />
|
||
</Col>
|
||
<Col :span="6">
|
||
<Statistic title="启用缓存" :value="overallStats.cachedApis" :value-style="{ color: '#1890ff' }" />
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
<!-- 下方:详细统计 -->
|
||
<Card size="small">
|
||
<!-- API选择和时间范围 -->
|
||
<div class="filter-section mb-4">
|
||
<Space wrap>
|
||
<span>选择API:</span>
|
||
<Select
|
||
v-model:value="selectedApiId"
|
||
style="width: 300px"
|
||
placeholder="请选择API"
|
||
>
|
||
<Select.Option v-for="api in apiList" :key="api.apiId" :value="api.apiId">
|
||
{{ api.apiName }} ({{ api.apiPath }})
|
||
</Select.Option>
|
||
</Select>
|
||
|
||
<span>时间范围:</span>
|
||
<DatePicker.RangePicker
|
||
v-model:value="timeRange"
|
||
style="width: 240px"
|
||
/>
|
||
|
||
<Button type="primary" :loading="loading" @click="handleQuery">
|
||
查询
|
||
</Button>
|
||
|
||
<Button :loading="loading" @click="handleRefresh">
|
||
刷新
|
||
</Button>
|
||
|
||
<Button @click="handleResetTimeRange">
|
||
重置
|
||
</Button>
|
||
|
||
<Divider type="vertical" />
|
||
|
||
<Tooltip title="开启后将按设定的间隔自动刷新统计数据">
|
||
<Space>
|
||
<span>自动刷新:</span>
|
||
<Switch v-model:checked="autoRefreshEnabled" @change="toggleAutoRefresh" />
|
||
</Space>
|
||
</Tooltip>
|
||
|
||
<Tooltip title="自动刷新的时间间隔(秒)">
|
||
<Space v-if="autoRefreshEnabled">
|
||
<span>间隔:</span>
|
||
<InputNumber
|
||
v-model:value="refreshInterval"
|
||
:min="10"
|
||
:max="300"
|
||
:step="10"
|
||
style="width: 80px"
|
||
/>
|
||
<span>秒</span>
|
||
</Space>
|
||
</Tooltip>
|
||
|
||
<Tooltip title="响应时间超过此阈值的调用视为慢查询">
|
||
<Space>
|
||
<span>慢查询阈值:</span>
|
||
<InputNumber
|
||
v-model:value="slowQueryThreshold"
|
||
:min="100"
|
||
:max="10000"
|
||
:step="100"
|
||
style="width: 100px"
|
||
/>
|
||
<span>ms</span>
|
||
</Space>
|
||
</Tooltip>
|
||
</Space>
|
||
</div>
|
||
|
||
<!-- Loading提示 -->
|
||
<Spin :spinning="loading" tip="正在加载统计数据...">
|
||
<!-- 统计卡片 -->
|
||
<Row :gutter="16" class="mb-4">
|
||
<Col :span="3">
|
||
<Statistic title="总调用次数" :value="statsData.totalCalls" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="成功次数" :value="statsData.successCalls" :value-style="{ color: '#3f8600' }" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="错误次数" :value="statsData.errorCalls" :value-style="{ color: '#cf1322' }" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="错误率" :value="statsData.errorRate.toFixed(2)" suffix="%" :value-style="{ color: statsData.errorRate > 5 ? '#cf1322' : '#3f8600' }" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="平均响应时间" :value="statsData.avgResponseTime" suffix="ms" :value-style="{ color: statsData.avgResponseTime > slowQueryThreshold ? '#faad14' : '#1890ff' }" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="最大响应时间" :value="statsData.maxResponseTime" suffix="ms" :value-style="{ color: statsData.maxResponseTime > slowQueryThreshold ? '#cf1322' : '#1890ff' }" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="最小响应时间" :value="statsData.minResponseTime" suffix="ms" />
|
||
</Col>
|
||
<Col :span="3">
|
||
<Statistic title="慢查询次数" :value="statsData.slowCalls" :value-style="{ color: '#faad14' }">
|
||
<template #suffix>
|
||
<Tooltip title="响应时间超过阈值的调用">
|
||
<Tag color="warning">>{{ slowQueryThreshold }}ms</Tag>
|
||
</Tooltip>
|
||
</template>
|
||
</Statistic>
|
||
</Col>
|
||
</Row>
|
||
|
||
<!-- 性能指标卡片 -->
|
||
<Row :gutter="16" class="mb-4">
|
||
<Col :span="6">
|
||
<Card size="small" title="性能健康度" :bordered="false">
|
||
<Progress
|
||
:percent="calculateHealthScore()"
|
||
:stroke-color="getHealthColor()"
|
||
trail-color="#f0f0f0"
|
||
/>
|
||
<div class="metric-desc">{{ getHealthDescription() }}</div>
|
||
</Card>
|
||
</Col>
|
||
<Col :span="6">
|
||
<Card size="small" title="成功率" :bordered="false">
|
||
<Progress
|
||
:percent="100 - statsData.errorRate"
|
||
:stroke-color="{ '0%': '#87d068', '100%': '#3f8600' }"
|
||
trail-color="#f0f0f0"
|
||
/>
|
||
<div class="metric-desc">成功 {{ statsData.successCalls }} / 总计 {{ statsData.totalCalls }}</div>
|
||
</Card>
|
||
</Col>
|
||
<Col :span="6">
|
||
<Card size="small" title="响应时间分布" :bordered="false">
|
||
<Progress
|
||
:percent="calculateResponseTimeScore()"
|
||
:stroke-color="getResponseTimeColor()"
|
||
trail-color="#f0f0f0"
|
||
/>
|
||
<div class="metric-desc">平均 {{ statsData.avgResponseTime }}ms / 最大 {{ statsData.maxResponseTime }}ms</div>
|
||
</Card>
|
||
</Col>
|
||
<Col :span="6">
|
||
<Card size="small" title="缓存状态" :bordered="false">
|
||
<Statistic :value="statsData.cacheEnabled ? '已启用' : '未启用'" :value-style="{ color: statsData.cacheEnabled ? '#3f8600' : '#8c8c8c' }" />
|
||
<div class="metric-desc">缓存可提升查询性能</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
<!-- 错误率进度条 -->
|
||
<div class="mb-4">
|
||
<Progress
|
||
:percent="100 - statsData.errorRate"
|
||
:stroke-color="{ '0%': '#3f8600', '100%': '#87d068' }"
|
||
:trail-color="statsData.errorRate > 10 ? '#cf1322' : '#f0f0f0'"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Tabs切换 -->
|
||
<Tabs v-model:activeKey="currentTab">
|
||
<TabPane key="overview" tab="统计概览">
|
||
<Empty v-if="!selectedApiId" description="请选择API查看统计" />
|
||
<Alert v-else-if="statsData.totalCalls === 0" type="info">
|
||
<template #message>
|
||
<div>
|
||
<strong>该API暂无调用记录</strong>
|
||
<br />
|
||
<small>请先调用动态API触发统计记录,然后刷新监控页面查看数据</small>
|
||
</div>
|
||
</template>
|
||
</Alert>
|
||
<div v-else>
|
||
<Alert type="success" message="统计功能已完善,包含性能健康度、成功率、响应时间分布等多维度分析" />
|
||
|
||
<!-- 性能建议 -->
|
||
<Card size="small" title="性能优化建议" class="mt-4" v-if="statsData.totalCalls > 0">
|
||
<div v-if="statsData.errorRate > 5">
|
||
<Tag color="error">高错误率</Tag>
|
||
<span>错误率超过5%,建议检查API配置和SQL语句</span>
|
||
</div>
|
||
<div v-if="statsData.avgResponseTime > slowQueryThreshold">
|
||
<Tag color="warning">慢查询较多</Tag>
|
||
<span>平均响应时间超过阈值,建议优化SQL查询或启用缓存</span>
|
||
</div>
|
||
<div v-if="!statsData.cacheEnabled && statsData.totalCalls > 10">
|
||
<Tag color="info">未启用缓存</Tag>
|
||
<span>调用次数较多但未启用缓存,建议启用缓存以提升性能</span>
|
||
</div>
|
||
<div v-if="statsData.errorRate <= 1 && statsData.avgResponseTime <= slowQueryThreshold / 2">
|
||
<Tag color="success">性能优秀</Tag>
|
||
<span>API运行状态良好,继续保持</span>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</TabPane>
|
||
|
||
<TabPane key="errorLog" tab="错误日志">
|
||
<Empty v-if="errorLogData.length === 0" description="暂无错误日志" />
|
||
<div v-else>
|
||
<Alert type="warning" class="mb-2">
|
||
<template #message>
|
||
<span>最近发现 {{ errorLogData.length }} 条错误记录,建议及时处理</span>
|
||
</template>
|
||
</Alert>
|
||
<Table
|
||
:columns="errorLogColumns"
|
||
:data-source="errorLogData"
|
||
:pagination="{ pageSize: 20 }"
|
||
size="small"
|
||
:scroll="{ x: 1000 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.dataIndex === 'callTime'">
|
||
{{ record.callTime }}
|
||
</template>
|
||
<template v-if="column.dataIndex === 'errorMessage'">
|
||
<Tooltip :title="record.errorMessage">
|
||
<Tag color="error">{{ record.errorMessage.substring(0, 50) }}...</Tag>
|
||
</Tooltip>
|
||
</template>
|
||
<template v-if="column.dataIndex === 'action'">
|
||
<Button type="link" size="small" @click="viewErrorDetail(record)">
|
||
详情
|
||
</Button>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</div>
|
||
</TabPane>
|
||
</Tabs>
|
||
</Spin>
|
||
</Card>
|
||
|
||
<!-- 错误详情弹窗 -->
|
||
<ErrorDetailModal
|
||
v-model:visible="errorDetailVisible"
|
||
:record="selectedErrorRecord"
|
||
@close="closeErrorDetail"
|
||
/>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filter-section {
|
||
padding: 16px;
|
||
background: #fafafa;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.mb-4 {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.mb-2 {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.mt-4 {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.metric-desc {
|
||
font-size: 12px;
|
||
color: #8c8c8c;
|
||
margin-top: 8px;
|
||
}
|
||
</style> |