Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

963
pages/sense/analysis.uvue Normal file
View File

@@ -0,0 +1,963 @@
<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);
}
}