Files
akmon/pages/sense/analysis.uvue
2026-01-20 08:04:15 +08:00

963 lines
24 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view direction="vertical" class="analysis-container">
<!-- 头部导航 -->
<view class="header">
<text class="title">数据分析</text>
<view class="header-actions">
<button class="export-btn" @click="exportReport">导出报告</button>
<button class="refresh-btn" @click="refreshAnalysis">刷新</button>
</view>
</view>
<!-- 时间范围选择 -->
<view class="time-range-card">
<text class="card-title">分析时间范围</text>
<view class="time-range-tabs">
<button class="time-tab" :class="{ active: activeTimeRange == range }"
v-for="(range, index) in timeRanges" :key="index" @click="selectTimeRange(range)">
{{ getTimeRangeLabel(range) }}
</button>
</view>
</view>
<!-- 总体健康评分 -->
<view class="health-score-card">
<text class="card-title">健康评分</text>
<view class="score-display">
<view class="score-circle">
<text class="score-value">{{ overallScore }}</text>
<text class="score-label">分</text>
</view>
<view class="score-details">
<view class="score-item" v-for="(item, index) in scoreBreakdown" :key="index">
<text class="score-category">{{ item.category }}</text>
<view class="score-bar">
<view class="score-progress" :style="{ width: item.percentage + '%' }"></view>
</view>
<text class="score-number">{{ item.score }}</text>
</view>
</view>
</view>
</view>
<!-- 指标趋势分析 -->
<view class="trends-card">
<text class="card-title">指标趋势</text>
<view class="trend-tabs">
<button class="trend-tab" :class="{ active: activeTrendType == type }"
v-for="(type, index) in trendTypes" :key="index" @click="selectTrendType(type)">
{{ getTrendTypeLabel(type) }}
</button>
</view>
<ak-charts :option="trendChartOption" :canvas-id="'trend-analysis-chart'" class="trend-chart" />
<view class="trend-summary">
<text class="trend-text">{{ trendSummary }}</text>
</view>
</view>
<!-- 异常检测 -->
<view class="anomaly-card" v-if="anomalies.length > 0">
<text class="card-title">异常检测</text>
<view class="anomaly-list">
<view class="anomaly-item" v-for="(anomaly, index) in anomalies" :key="index">
<view class="anomaly-header">
<text class="anomaly-type">{{ anomaly.type }}</text>
<text class="anomaly-severity" :class="getSeverityClass(anomaly.severity)">
{{ anomaly.severity }}
</text>
</view>
<text class="anomaly-description">{{ anomaly.description }}</text>
<text class="anomaly-time">{{ formatTime(anomaly.detected_at) }}</text>
</view>
</view>
</view>
<!-- AI分析报告 -->
<view class="ai-report-card">
<text class="card-title">AI分析报告</text>
<view class="report-status" v-if="isAnalyzing">
<text class="analyzing-text">AI正在分析您的数据...</text>
<view class="analyzing-indicator"></view>
</view>
<view class="report-content" v-else-if="aiReport !== null">
<view class="report-section">
<text class="section-title">健康状况评估</text>
<text class="section-content">{{ aiReport?.health_assessment ?? '' }}</text>
</view>
<view class="report-section">
<text class="section-title">趋势分析</text>
<text class="section-content">{{ aiReport?.trend_analysis ?? '' }}</text>
</view>
<view class="report-section">
<text class="section-title">个性化建议</text>
<view class="recommendations">
<view class="recommendation-item" v-for="(rec, index) in (aiReport?.recommendations ?? [])"
:key="index">
<text class="rec-icon">💡</text>
<text class="rec-text">{{ rec }}</text>
</view>
</view>
</view>
</view>
<button class="analyze-btn" v-else @click="startAIAnalysis">开始AI分析</button>
</view>
<!-- 数据对比 -->
<view class="comparison-card">
<text class="card-title">数据对比</text>
<view class="comparison-options">
<view class="picker-view" @click="showComparisonPicker">
<text>与{{ comparisonPeriods[comparisonIndex] }}对比</text>
<text class="picker-arrow">▼</text>
</view>
</view>
<view class="comparison-results" v-if="comparisonData.length > 0">
<view class="comparison-item" v-for="(item, index) in comparisonData" :key="index">
<text class="comparison-metric">{{ item.metric_name }}</text>
<view class="comparison-values">
<text class="current-value">当前: {{ item.current_period }}</text>
<text class="previous-value">之前: {{ item.previous_period }}</text>
<text class="change-value" :class="getChangeClass(item.change_percentage)">
{{ item.change_percentage > 0 ? '+' : '' }}{{ item.change_percentage }}%
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import akCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { SensorMeasurement, HealthScoreBreakdown, HealthAnomaly, AIAnalysisReport, TrendComparison } from './types.uts'
import { setClipboardData, SetClipboardDataOption } from '@/uni_modules/lime-clipboard'
// 响应式数据
const activeTimeRange = ref<string>('7d')
const activeTrendType = ref<string>('heart_rate')
const overallScore = ref<number>(82)
const scoreBreakdown = ref<Array<HealthScoreBreakdown>>([])
const trendChartOption = ref<UTSJSONObject>(new UTSJSONObject())
const trendSummary = ref<string>('')
const anomalies = ref<Array<HealthAnomaly>>([])
const aiReport = ref<AIAnalysisReport | null>(null)
const isAnalyzing = ref<boolean>(false)
const comparisonIndex = ref<number>(0)
const comparisonData = ref<Array<TrendComparison>>([])
// 选项数据
const timeRanges = ['24h', '7d', '30d', '90d']
const trendTypes = ['heart_rate', 'steps', 'spo2', 'bp', 'temp']
const comparisonPeriods = ['上周', '上月', '上季度', '去年同期']
const userId = 'eed3824b-bba1-4309-8048-19d17367c084'
async function loadHealthScore() {
// 模拟健康评分数据
const mockScoreBreakdown : Array<HealthScoreBreakdown> = [
{
category: '心率',
score: 85,
percentage: 85,
trend: 'stable',
color: '#4CAF50'
},
{
category: '运动',
score: 78,
percentage: 78,
trend: 'up',
color: '#2196F3'
},
{
category: '睡眠',
score: 80,
percentage: 80,
trend: 'down',
color: '#9C27B0'
},
{
category: '血氧',
score: 88,
percentage: 88,
trend: 'stable',
color: '#FF9800'
}
]
scoreBreakdown.value = mockScoreBreakdown
}
function exportReport() {
// 构建报告数据
const reportData = new UTSJSONObject()
const currentAiReport = aiReport.value // 避免Smart cast问题
reportData.set('user_id', userId)
reportData.set('time_range', activeTimeRange.value)
reportData.set('overall_score', overallScore.value)
reportData.set('score_breakdown', scoreBreakdown.value)
reportData.set('anomalies', anomalies.value)
reportData.set('ai_report', currentAiReport)
reportData.set('generated_at', new Date().toISOString())
const jsonStr = JSON.stringify(reportData, null, 2)
setClipboardData({
data: jsonStr,
success: (res : UniError) => {
uni.showToast({
title: '报告已复制到剪贴板',
icon: 'success'
})
}
} as SetClipboardDataOption)
}
// 工具函数
function getTimeRangeLabel(range : string) : string {
const labels = new Map<string, string>()
labels.set('24h', '24小时')
labels.set('7d', '7天')
labels.set('30d', '30天')
labels.set('90d', '90天')
return labels.get(range) ?? range
}
function getTrendTypeLabel(type : string) : string {
const labels = new Map<string, string>()
labels.set('heart_rate', '心率')
labels.set('steps', '步数')
labels.set('spo2', '血氧')
labels.set('bp', '血压')
labels.set('temp', '体温')
return labels.get(type) ?? type
}
function getTrendColor(type : string) : string {
const colors = new Map<string, string>()
colors.set('heart_rate', '#FF6B6B')
colors.set('steps', '#4ECDC4')
colors.set('spo2', '#45B7D1')
colors.set('bp', '#AB47BC')
colors.set('temp', '#FFA726')
return colors.get(type) ?? '#2196F3'
}
function getSeverityClass(severity : string) : string {
if (severity == '高') return 'severity-high'
if (severity == '中等') return 'severity-medium'
return 'severity-low'
}
function getChangeClass(change : number) : string {
if (change > 0) return 'change-positive'
if (change < 0) return 'change-negative'
return 'change-neutral'
}
function formatTime(timeStr : string) : string {
if (timeStr == '') return '--'
const time = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - time.getTime()
if (diff < 3600000) { // 1小时内
const minutes = Math.floor(diff / 60000)
return `${minutes}分钟前`
} else if (diff < 86400000) { // 24小时内
const hours = Math.floor(diff / 3600000)
return `${hours}小时前`
} else {
const days = Math.floor(diff / 86400000)
return `${days}天前`
}
}
function generateTrendSummary(data : Array<SensorMeasurement>) {
if (data.length == 0) {
trendSummary.value = '暂无数据'
return
}
// 计算平均值和趋势
let sum = 0
let count = 0
const values : Array<number> = []
for (let i : Int = 0; i < data.length; i++) {
const item = data[i]
const rawData = item.raw_data
if (rawData !== null) {
let value : number = 0
if (activeTrendType.value == 'heart_rate') {
value = rawData.getNumber('bpm') ?? 0
} else if (activeTrendType.value == 'steps') {
value = rawData.getNumber('count') ?? 0
} else if (activeTrendType.value == 'spo2') {
value = rawData.getNumber('spo2') ?? 0
} else if (activeTrendType.value == 'bp') {
value = rawData.getNumber('systolic') ?? 0
} else if (activeTrendType.value == 'temp') {
value = rawData.getNumber('temp') ?? 0
}
if (value > 0) {
sum += value
count++
values.push(value)
}
}
}
if (count > 0) {
const average = sum / count
const typeLabel = getTrendTypeLabel(activeTrendType.value)
const timeLabel = getTimeRangeLabel(activeTimeRange.value)
// 计算趋势方向
let trendDirection = '稳定'
if (values.length > 1) {
const firstHalf = values.slice(0, Math.floor(values.length / 2))
const secondHalf = values.slice(Math.floor(values.length / 2))
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
const change = ((secondAvg - firstAvg) / firstAvg) * 100
if (change > 5) {
trendDirection = '上升'
} else if (change < -5) {
trendDirection = '下降'
}
}
trendSummary.value = `${timeLabel}内${typeLabel}平均值为${average.toFixed(1)},整体趋势${trendDirection}`
}
}
function updateTrendChart(data : Array<SensorMeasurement>) {
const chartData : Array<number> = []
const chartLabels : Array<string> = []
for (let i : Int = 0; i < data.length; i++) {
const item = data[i]
const rawData = item.raw_data
if (rawData !== null) {
let value : number = 0
// 根据类型提取数值
if (activeTrendType.value == 'heart_rate') {
value = rawData.getNumber('bpm') ?? 0
} else if (activeTrendType.value == 'steps') {
value = rawData.getNumber('count') ?? 0
} else if (activeTrendType.value == 'spo2') {
value = rawData.getNumber('spo2') ?? 0
} else if (activeTrendType.value == 'bp') {
value = rawData.getNumber('systolic') ?? 0
} else if (activeTrendType.value == 'temp') {
value = rawData.getNumber('temp') ?? 0
}
chartData.push(value)
// 格式化时间标签
const timeStr = item.measured_at
const time = new Date(timeStr)
let label = ''
if (activeTimeRange.value == '24h') {
label = time.getHours().toString().padStart(2, '0') + ':' +
time.getMinutes().toString().padStart(2, '0')
} else {
label = (time.getMonth() + 1).toString() + '/' + time.getDate().toString()
}
chartLabels.push(label)
}
}
// 构建图表配置
const option = new UTSJSONObject()
option.set('type', 'line')
option.set('data', chartData)
option.set('labels', chartLabels)
option.set('color', getTrendColor(activeTrendType.value))
option.set('smooth', true)
trendChartOption.value = option
}
async function loadTrendData() {
if (supa == null) return
try {
// 根据时间范围计算开始时间
const endTime = new Date()
const startTime = new Date()
if (activeTimeRange.value == '24h') {
startTime.setDate(startTime.getDate() - 1)
} else if (activeTimeRange.value == '7d') {
startTime.setDate(startTime.getDate() - 7)
} else if (activeTimeRange.value == '30d') {
startTime.setDate(startTime.getDate() - 30)
} else if (activeTimeRange.value == '90d') {
startTime.setDate(startTime.getDate() - 90)
}
const result = await supa.from('ss_sensor_measurements')
.eq('user_id', userId)
.eq('measurement_type', activeTrendType.value)
.gte('measured_at', startTime.toISOString())
.lte('measured_at', endTime.toISOString())
.order('measured_at', { ascending: true })
.limit(100)
.executeAs<Array<SensorMeasurement>>()
if (result.data !== null) {
if (Array.isArray(result.data)) {
const dataArray = result.data as Array<any>
const trendData : Array<SensorMeasurement> = []
for (let i = 0; i < dataArray.length; i++) {
trendData.push(dataArray[i] as SensorMeasurement)
}
updateTrendChart(trendData)
generateTrendSummary(trendData)
} else {
updateTrendChart([])
generateTrendSummary([])
}
} else {
updateTrendChart([])
generateTrendSummary([])
}
} catch (e) {
console.log('加载趋势数据失败:', e)
}
}
async function loadAnomalies() {
// 模拟异常数据
const mockAnomalies : Array<HealthAnomaly> = [
{
id: '1',
type: '心率异常',
severity: 'medium',
description: '检测到心率持续偏高,建议关注',
detected_at: new Date(Date.now() - 3600000).toISOString(),
resolved: false
},
{
id: '2',
type: '血压波动',
severity: 'low',
description: '血压值有轻微波动,持续观察',
detected_at: new Date(Date.now() - 7200000).toISOString(),
resolved: true
}
]
anomalies.value = mockAnomalies
}
async function loadComparisonData() {
// 模拟对比数据
const mockComparison : Array<TrendComparison> = [
{
current_period: 72,
previous_period: 75,
change_percentage: -4.0,
change_direction: 'down',
metric_name: '平均心率',
time_range: activeTimeRange.value
},
{
current_period: 8500,
previous_period: 7800,
change_percentage: 9.0,
change_direction: 'up',
metric_name: '日均步数',
time_range: activeTimeRange.value
}]
comparisonData.value = mockComparison
}
async function startAIAnalysis() {
if (supa == null) return
isAnalyzing.value = true
try {
// 调用AI分析RPC
const analysisParams = new UTSJSONObject()
analysisParams.set('user_id', userId)
analysisParams.set('time_range', activeTimeRange.value)
analysisParams.set('analysis_types', ['health_assessment', 'trend_analysis', 'recommendations'])
const result = await supa.rpc('generate_health_analysis', analysisParams)
if (result.data !== null && Array.isArray(result.data)) {
const dataArray = result.data as Array<any>
if (dataArray.length > 0) {
const resultData = dataArray
const reportData = resultData[0] as UTSJSONObject
const mockReport : AIAnalysisReport = {
health_assessment: reportData.getString('health_assessment') ?? '健康评估数据获取失败',
trend_analysis: reportData.getString('trend_analysis') ?? '趋势分析数据获取失败',
recommendations: reportData.getArray('recommendations') as string[] ?? [],
risk_factors: reportData.getArray('risk_factors') as string[] ?? [],
generated_at: reportData.getString('generated_at') ?? new Date().toISOString()
}
aiReport.value = mockReport
} else {
// 模拟AI报告
const mockReport : AIAnalysisReport = {
health_assessment: '您的整体健康状况良好,各项指标基本正常。心率变异性较为稳定,运动量适中。',
trend_analysis: '近期心率呈轻微下降趋势,说明心血管健康状况有所改善。步数波动较大,建议保持规律运动。',
recommendations: [
'建议每天保持8000步以上的运动量',
'注意监测血压变化,定期检查',
'保持良好的睡眠习惯每天7-8小时',
'饮食均衡,减少高盐高脂食物摄入'
],
risk_factors: ['血压偏高', '运动不规律'],
generated_at: new Date().toISOString()
}
aiReport.value = mockReport
}
}
} catch (e) {
console.log('AI分析失败:', e)
uni.showToast({
title: 'AI分析失败',
icon: 'error'
})
} finally {
isAnalyzing.value = false
}
}
async function initializeData() {
await loadHealthScore()
await loadTrendData()
await loadAnomalies()
await loadComparisonData()
}
function selectTimeRange(range : string) {
activeTimeRange.value = range
loadTrendData()
}
function selectTrendType(type : string) {
activeTrendType.value = type
loadTrendData()
}
function showComparisonPicker() {
uni.showActionSheet({
itemList: comparisonPeriods,
success: (res) => {
if (res.tapIndex >= 0) {
comparisonIndex.value = res.tapIndex
loadComparisonData()
}
}
})
}
function refreshAnalysis() {
initializeData()
}
onMounted(() => {
initializeData()
})
</script>
<style scoped>
.analysis-container {
flex: 1;
padding: 20rpx;
background-color: #f5f5f5;
}
.header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding: 20rpx;
background-color: #ffffff;
border-radius: 12rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
}
.header-actions {
flex-direction: row;
}
.export-btn,
.refresh-btn {
padding: 16rpx 24rpx;
margin-left: 16rpx;
border-radius: 8rpx;
font-size: 28rpx;
border: none;
}
.export-btn {
background-color: #67C23A;
color: #ffffff;
}
.refresh-btn {
background-color: #409EFF;
color: #ffffff;
}
.time-range-card,
.health-score-card,
.trends-card,
.anomaly-card,
.ai-report-card,
.comparison-card {
margin-bottom: 20rpx;
padding: 24rpx;
background-color: #ffffff;
border-radius: 12rpx;
}
.card-title {
font-size: 30rpx;
font-weight: bold;
color: #333333;
margin-bottom: 20rpx;
}
.time-range-tabs,
.trend-tabs {
flex-direction: row;
justify-content: space-around;
}
.time-tab,
.trend-tab {
flex: 1;
padding: 16rpx;
margin: 0 8rpx;
background-color: #f0f0f0;
color: #666666;
border-radius: 8rpx;
font-size: 26rpx;
border: none;
text-align: center;
}
.time-tab.active,
.trend-tab.active {
background-color: #409EFF;
color: #ffffff;
}
.score-display {
flex-direction: row;
align-items: center;
}
.score-circle {
width: 200rpx;
height: 200rpx;
border-radius: 100rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
justify-content: center;
align-items: center;
margin-right: 40rpx;
}
.score-value {
font-size: 64rpx;
font-weight: bold;
color: #ffffff;
}
.score-label {
font-size: 24rpx;
color: #ffffff;
margin-top: -8rpx;
}
.score-details {
flex: 1;
}
.score-item {
flex-direction: row;
align-items: center;
margin-bottom: 16rpx;
}
.score-category {
width: 100rpx;
font-size: 26rpx;
color: #333333;
}
.score-bar {
flex: 1;
height: 16rpx;
background-color: #f0f0f0;
border-radius: 8rpx;
margin: 0 16rpx;
overflow: hidden;
}
.score-progress {
height: 100%;
background-color: #409EFF;
border-radius: 8rpx;
}
.score-number {
width: 60rpx;
font-size: 24rpx;
color: #666666;
text-align: right;
}
.trend-chart {
height: 400rpx;
margin: 20rpx 0;
}
.trend-summary {
padding: 20rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
}
.trend-text {
font-size: 26rpx;
color: #666666;
line-height: 1.5;
}
.anomaly-list {
flex-direction: column;
}
.anomaly-item {
padding: 20rpx;
margin-bottom: 16rpx;
background-color: #fff5f5;
border-radius: 8rpx;
border-left: 8rpx solid #f56c6c;
}
.anomaly-header {
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.anomaly-type {
font-size: 28rpx;
font-weight: bold;
color: #333333;
}
.anomaly-severity {
padding: 6rpx 12rpx;
border-radius: 12rpx;
font-size: 22rpx;
}
.severity-high {
background-color: #fde2e2;
color: #dc2626;
}
.severity-medium {
background-color: #fef3c7;
color: #d97706;
}
.severity-low {
background-color: #dcfce7;
color: #16a34a;
}
.anomaly-description {
font-size: 26rpx;
color: #666666;
margin-bottom: 8rpx;
}
.anomaly-time {
font-size: 22rpx;
color: #999999;
}
.report-status {
align-items: center;
padding: 40rpx 0;
}
.analyzing-text {
font-size: 28rpx;
color: #666666;
margin-bottom: 20rpx;
}
.analyzing-indicator {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #f0f0f0;
border-top: 4rpx solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.report-content {
flex-direction: column;
}
.report-section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333333;
margin-bottom: 12rpx;
}
.section-content {
font-size: 26rpx;
color: #666666;
line-height: 1.6;
}
.recommendations {
flex-direction: column;
}
.recommendation-item {
flex-direction: row;
align-items: flex-start;
margin-bottom: 12rpx;
}
.rec-icon {
font-size: 28rpx;
margin-right: 12rpx;
}
.rec-text {
flex: 1;
font-size: 26rpx;
color: #666666;
line-height: 1.5;
}
.analyze-btn {
padding: 24rpx 48rpx;
background-color: #409EFF;
color: #ffffff;
border-radius: 12rpx;
font-size: 28rpx;
border: none;
align-self: center;
}
.comparison-options {
margin-bottom: 20rpx;
}
.picker-view {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx;
border: 2rpx solid #e0e0e0;
border-radius: 8rpx;
}
.picker-arrow {
color: #999999;
font-size: 24rpx;
}
.comparison-results {
flex-direction: column;
}
.comparison-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.comparison-metric {
font-size: 28rpx;
color: #333333;
font-weight: bold;
}
.comparison-values {
flex-direction: column;
align-items: flex-end;
}
.current-value,
.previous-value {
font-size: 24rpx;
color: #666666;
margin-bottom: 4rpx;
}
.change-value {
font-size: 26rpx;
font-weight: bold;
}
.change-positive {
color: #67C23A;
}
.change-negative {
color: #F56C6C;
}
.change-neutral {
color: #909399;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>