- 设计并实现了开发者中心与企业控制台两大模块 - 按用户角色区分开发者和企业客户,支持多项目类型及成员管理 - 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面 - 实现应用版本发布、消息通知中心、权限审批与开发者申请流程 - 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能 - 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块 - 优化后端API,支持已登录和未注册用户不同加入应用流程 - 前端按钮统一采用微信手机号授权,完善用户授权体验 - 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库 - 增加详细的类型定义文件,提升项目类型安全 - 新增超过55个页面及60个API接口,扩展应用功能和服务体系 - 完成全面的样式设计,实现一致的视觉风格和交互体验
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
/**
|
|
* 运营监控页面 - 数据分析看板
|
|
*/
|
|
import { useState, useEffect } from 'react'
|
|
import { View, Text, ScrollView } from '@tarojs/components'
|
|
import Taro from '@tarojs/taro'
|
|
import { AtTabs, AtTabsPane, AtTag, AtActivityIndicator } from 'taro-ui'
|
|
import {
|
|
getOverviewStats,
|
|
getRealtimeData,
|
|
getPerformanceMetrics,
|
|
getDeviceStats,
|
|
getSourceStats,
|
|
getPageStats,
|
|
getRegionStats,
|
|
} from '../../../../api/analytics'
|
|
import type { OverviewStats, PerformanceMetric, DeviceStat, SourceStat, PageStat, RegionStat } from '../../../../types/analytics'
|
|
import './analytics.scss'
|
|
|
|
// 格式化数字
|
|
const formatNumber = (num?: number) => {
|
|
if (!num) return '0'
|
|
if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿'
|
|
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
|
return num.toLocaleString()
|
|
}
|
|
|
|
// 格式化百分比
|
|
const formatPercent = (num?: number) => {
|
|
if (!num) return '0%'
|
|
return num.toFixed(1) + '%'
|
|
}
|
|
|
|
export default function Analytics() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [realtime, setRealtime] = useState({
|
|
uv: 0,
|
|
pv: 0,
|
|
apiCalls: 0,
|
|
errors: 0,
|
|
activeUsers: 0,
|
|
})
|
|
const [overview, setOverview] = useState<OverviewStats>({})
|
|
const [metrics, setMetrics] = useState<PerformanceMetric[]>([])
|
|
const [devices, setDevices] = useState<DeviceStat[]>([])
|
|
const [sources, setSources] = useState<SourceStat[]>([])
|
|
const [pages, setPages] = useState<PageStat[]>([])
|
|
const [regions, setRegions] = useState<RegionStat[]>([])
|
|
const [activeTab, setActiveTab] = useState(0)
|
|
const [appId, setAppId] = useState('')
|
|
|
|
useEffect(() => {
|
|
const pages = Taro.getCurrentPages()
|
|
const current = pages[pages.length - 1]
|
|
if (current?.options?.appId) {
|
|
setAppId(current.options.appId)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (appId) {
|
|
fetchData()
|
|
// 实时数据每 30 秒刷新
|
|
const timer = setInterval(() => {
|
|
fetchRealtimeData()
|
|
}, 30000)
|
|
return () => clearInterval(timer)
|
|
}
|
|
}, [appId])
|
|
|
|
// 获取所有数据
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true)
|
|
await Promise.all([
|
|
fetchRealtimeData(),
|
|
fetchOverviewData(),
|
|
fetchMetricsData(),
|
|
fetchDeviceData(),
|
|
fetchSourceData(),
|
|
fetchPageData(),
|
|
fetchRegionData(),
|
|
])
|
|
} catch (err) {
|
|
console.error('获取数据失败', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// 获取实时数据
|
|
const fetchRealtimeData = async () => {
|
|
try {
|
|
const res = await getRealtimeData(Number(appId))
|
|
if (res.data) {
|
|
setRealtime(res.data)
|
|
}
|
|
} catch (err) {
|
|
console.error('获取实时数据失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取概览数据
|
|
const fetchOverviewData = async () => {
|
|
try {
|
|
const res = await getOverviewStats(Number(appId))
|
|
if (res.data) {
|
|
setOverview(res.data)
|
|
}
|
|
} catch (err) {
|
|
console.error('获取概览数据失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取性能指标
|
|
const fetchMetricsData = async () => {
|
|
try {
|
|
const res = await getPerformanceMetrics(Number(appId))
|
|
if (res.data) {
|
|
setMetrics(res.data.list || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('获取性能指标失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取设备分布
|
|
const fetchDeviceData = async () => {
|
|
try {
|
|
const res = await getDeviceStats(Number(appId))
|
|
if (res.data) {
|
|
setDevices(res.data.list || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('获取设备数据失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取来源分布
|
|
const fetchSourceData = async () => {
|
|
try {
|
|
const res = await getSourceStats(Number(appId))
|
|
if (res.data) {
|
|
setSources(res.data.list || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('获取来源数据失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取页面访问
|
|
const fetchPageData = async () => {
|
|
try {
|
|
const res = await getPageStats({
|
|
websiteId: Number(appId),
|
|
limit: 10,
|
|
})
|
|
if (res.data) {
|
|
setPages(res.data.list || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('获取页面数据失败', err)
|
|
}
|
|
}
|
|
|
|
// 获取区域分布
|
|
const fetchRegionData = async () => {
|
|
try {
|
|
const res = await getRegionStats(Number(appId))
|
|
if (res.data) {
|
|
setRegions(res.data.list || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('获取区域数据失败', err)
|
|
}
|
|
}
|
|
|
|
const tabs = [
|
|
{ title: '概览' },
|
|
{ title: '性能' },
|
|
{ title: '用户' },
|
|
{ title: '页面' },
|
|
]
|
|
|
|
return (
|
|
<View className="analytics-page">
|
|
{loading && realtime.uv === 0 ? (
|
|
<View className="loading-wrap">
|
|
<AtActivityIndicator size={32} />
|
|
<Text className="loading-text">加载中...</Text>
|
|
</View>
|
|
) : (
|
|
<>
|
|
{/* 实时数据 */}
|
|
<View className="realtime-card">
|
|
<View className="realtime-header">
|
|
<Text className="title">实时数据</Text>
|
|
<View className="live-dot">
|
|
<View className="dot" />
|
|
<Text>LIVE</Text>
|
|
</View>
|
|
</View>
|
|
<View className="realtime-stats">
|
|
<View className="stat-item">
|
|
<Text className="stat-value">{formatNumber(realtime.uv)}</Text>
|
|
<Text className="stat-label">实时 UV</Text>
|
|
</View>
|
|
<View className="stat-item">
|
|
<Text className="stat-value">{formatNumber(realtime.pv)}</Text>
|
|
<Text className="stat-label">实时 PV</Text>
|
|
</View>
|
|
<View className="stat-item">
|
|
<Text className="stat-value">{formatNumber(realtime.apiCalls)}</Text>
|
|
<Text className="stat-label">API 调用</Text>
|
|
</View>
|
|
<View className="stat-item">
|
|
<Text className={`stat-value ${realtime.errors > 0 ? 'error' : ''}`}>
|
|
{formatNumber(realtime.errors)}
|
|
</Text>
|
|
<Text className="stat-label">错误数</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 今日概览 */}
|
|
<View className="overview-stats">
|
|
<View className="stat-card">
|
|
<Text className="stat-value">{formatNumber(overview.todayUv)}</Text>
|
|
<Text className="stat-label">今日 UV</Text>
|
|
</View>
|
|
<View className="stat-card">
|
|
<Text className="stat-value">{formatNumber(overview.todayPv)}</Text>
|
|
<Text className="stat-label">今日 PV</Text>
|
|
</View>
|
|
<View className="stat-card">
|
|
<Text className="stat-value">{formatNumber(overview.todayApiCalls)}</Text>
|
|
<Text className="stat-label">API 调用</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Tab 切换 */}
|
|
<AtTabs
|
|
current={activeTab}
|
|
tabList={tabs}
|
|
scroll
|
|
onClick={(index) => setActiveTab(index)}
|
|
>
|
|
<AtTabsPane current={activeTab} index={0}>
|
|
<ScrollView scrollY className="tab-content">
|
|
{/* 趋势图表区 */}
|
|
<View className="chart-section">
|
|
<Text className="section-title">数据趋势</Text>
|
|
<View className="chart-placeholder">
|
|
<Text className="iconfont icon-chart" />
|
|
<Text className="placeholder-text">趋势图表</Text>
|
|
<Text className="placeholder-hint">可集成 ECharts 展示数据趋势</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 设备分布 */}
|
|
<View className="chart-section">
|
|
<Text className="section-title">设备分布</Text>
|
|
{devices.length > 0 ? (
|
|
<View className="pie-chart">
|
|
{devices.map((device, index) => (
|
|
<View className="pie-item" key={index}>
|
|
<View
|
|
className="pie-color"
|
|
style={{
|
|
background: ['#1890ff', '#52c41a', '#faad14', '#f5222d'][index % 4],
|
|
}}
|
|
/>
|
|
<Text className="pie-label">{device.device}</Text>
|
|
<Text className="pie-value">{formatPercent(device.percentage)}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View className="empty-data">暂无数据</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</AtTabsPane>
|
|
|
|
<AtTabsPane current={activeTab} index={1}>
|
|
<ScrollView scrollY className="tab-content">
|
|
{/* 性能指标 */}
|
|
<View className="metrics-section">
|
|
<Text className="section-title">核心指标</Text>
|
|
{metrics.length > 0 ? (
|
|
metrics.map((metric, index) => (
|
|
<View className="metric-item" key={index}>
|
|
<View className="metric-info">
|
|
<Text className="metric-name">{metric.metric}</Text>
|
|
<View className="metric-value-row">
|
|
<Text className="metric-value">{metric.value?.toFixed(2)}</Text>
|
|
<Text className="metric-unit">{metric.unit}</Text>
|
|
<AtTag
|
|
size="small"
|
|
type={metric.trend === 'up' ? 'success' : metric.trend === 'down' ? 'error' : 'primary'}
|
|
>
|
|
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
|
{Math.abs(metric.change || 0)}%
|
|
</AtTag>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))
|
|
) : (
|
|
<View className="empty-data">暂无数据</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 错误统计 */}
|
|
<View className="error-section">
|
|
<Text className="section-title">错误统计</Text>
|
|
<View className="error-summary">
|
|
<View className="error-item">
|
|
<Text className="error-value error">{overview.todayErrors || 0}</Text>
|
|
<Text className="error-label">今日错误</Text>
|
|
</View>
|
|
<View className="error-item">
|
|
<Text className="error-value warning">0</Text>
|
|
<Text className="error-label">严重错误</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
</AtTabsPane>
|
|
|
|
<AtTabsPane current={activeTab} index={2}>
|
|
<ScrollView scrollY className="tab-content">
|
|
{/* 用户统计 */}
|
|
<View className="user-stats">
|
|
<View className="user-card">
|
|
<Text className="user-value">{formatNumber(overview.activeUsers)}</Text>
|
|
<Text className="user-label">活跃用户</Text>
|
|
</View>
|
|
<View className="user-card">
|
|
<Text className="user-value">{formatNumber(overview.newUsers)}</Text>
|
|
<Text className="user-label">新增用户</Text>
|
|
</View>
|
|
<View className="user-card">
|
|
<Text className="user-value">{formatPercent(overview.retention)}</Text>
|
|
<Text className="user-label">留存率</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 区域分布 */}
|
|
<View className="region-section">
|
|
<Text className="section-title">地域分布</Text>
|
|
{regions.length > 0 ? (
|
|
regions.slice(0, 5).map((region, index) => (
|
|
<View className="region-item" key={index}>
|
|
<View className="region-rank">{index + 1}</View>
|
|
<Text className="region-name">{region.region}</Text>
|
|
<View className="region-bar">
|
|
<View
|
|
className="bar-fill"
|
|
style={{ width: (region.percentage || 0) + '%' }}
|
|
/>
|
|
</View>
|
|
<Text className="region-value">{formatPercent(region.percentage)}</Text>
|
|
</View>
|
|
))
|
|
) : (
|
|
<View className="empty-data">暂无数据</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 来源分布 */}
|
|
<View className="source-section">
|
|
<Text className="section-title">流量来源</Text>
|
|
{sources.length > 0 ? (
|
|
sources.map((source, index) => (
|
|
<View className="source-item" key={index}>
|
|
<View className="source-rank">{index + 1}</View>
|
|
<Text className="source-name">{source.source}</Text>
|
|
<Text className="source-value">{formatPercent(source.percentage)}</Text>
|
|
</View>
|
|
))
|
|
) : (
|
|
<View className="empty-data">暂无数据</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</AtTabsPane>
|
|
|
|
<AtTabsPane current={activeTab} index={3}>
|
|
<ScrollView scrollY className="tab-content">
|
|
{/* 页面排行 */}
|
|
<View className="page-section">
|
|
<Text className="section-title">页面访问排行</Text>
|
|
{pages.length > 0 ? (
|
|
pages.map((page, index) => (
|
|
<View className="page-item" key={index}>
|
|
<View className="page-rank">{index + 1}</View>
|
|
<View className="page-info">
|
|
<Text className="page-title">{page.title || page.path}</Text>
|
|
<Text className="page-path">{page.path}</Text>
|
|
</View>
|
|
<View className="page-stats">
|
|
<View className="page-stat">
|
|
<Text className="stat-num">{formatNumber(page.pv)}</Text>
|
|
<Text className="stat-label">PV</Text>
|
|
</View>
|
|
<View className="page-stat">
|
|
<Text className="stat-num">{formatNumber(page.uv)}</Text>
|
|
<Text className="stat-label">UV</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))
|
|
) : (
|
|
<View className="empty-data">暂无数据</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</AtTabsPane>
|
|
</AtTabs>
|
|
</>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|