Files
smt/src/pages/ai/index.vue
T
2026-03-18 19:24:51 +08:00

456 lines
9.9 KiB
Vue
Raw 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>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view class="hero-card">
<text class="hero-label">AI 建议</text>
<text class="hero-title">今日控烟节奏</text>
<text class="hero-desc">基于最近 3 天记录作息和默认控烟节奏生成建议</text>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">下次建议</text>
<text class="section-subtitle">首页会同步显示这里的最新结果</text>
</view>
<view class="primary-btn" @tap="handleAISuggest">
<text class="primary-btn-text">{{ aiLoading ? '生成中...' : actionText }}</text>
</view>
</view>
<view v-if="suggestedClock" class="suggested-pill">
<text class="suggested-pill-label">下次建议</text>
<text class="suggested-pill-value">{{ suggestedClock }}</text>
</view>
<view v-if="aiTimeNodes.length" class="timeline">
<view
v-for="(node, idx) in aiTimeNodesWithStatus"
:key="idx"
class="timeline-node"
:class="{
'timeline-node-past': node.status === 'past',
'timeline-node-current': node.status === 'current',
'timeline-node-future': node.status === 'future'
}"
>
<view class="timeline-dot"></view>
<text class="timeline-time">{{ node.time }}</text>
<view class="timeline-line" v-if="idx < aiTimeNodesWithStatus.length - 1"></view>
</view>
</view>
<view v-if="aiAdvice" class="advice-card">
<text class="advice-title">AI 文案</text>
<text class="advice-text">{{ aiAdvice }}</text>
</view>
<view v-if="!aiTimeNodes.length && !aiAdvice && !aiLoading" class="empty-card">
<text class="empty-title">还没有生成今日 AI 建议</text>
<text class="empty-desc">点击右上角生成系统会结合近 3 天记录给出建议时间点</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useLogin } from '@/hooks/useLogin'
const { waitForLogin } = useLogin()
const rewardAdUnitId = 'adunit-36e13d77e185f757'
const statusBarHeight = ref(0)
const homeData = ref(null)
const aiLoading = ref(false)
const homeTimer = computed(() => homeData.value?.timer || {})
const aiTimeNodes = computed(() => homeTimer.value?.ai_time_nodes || [])
const aiAdvice = computed(() => homeTimer.value?.ai_advice || '')
const actionText = computed(() => (aiTimeNodes.value.length > 0 ? '刷新' : '生成'))
const suggestedClock = computed(() => {
const timer = homeTimer.value
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (timer.next_suggested_at) {
const date = new Date(timer.next_suggested_at)
if (!isNaN(date.getTime())) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
return ''
})
const aiTimeNodesWithStatus = computed(() => {
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
let foundCurrent = false
return aiTimeNodes.value.map((time) => {
const [h, m] = time.split(':').map(Number)
const nodeMinutes = h * 60 + m
if (foundCurrent) return { time, status: 'future' }
if (nodeMinutes > nowMinutes) {
foundCurrent = true
return { time, status: 'current' }
}
return { time, status: 'past' }
})
})
function formatLocalDate(date = new Date()) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
async function fetchHomeData() {
const res = await api.getHome()
homeData.value = res.data || {}
}
async function runRewardedUnlock(onUnlocked) {
// #ifdef MP-WEIXIN
try {
const videoAd = wx.createRewardedVideoAd({
adUnitId: rewardAdUnitId
})
videoAd.onClose(async (res) => {
if (res && res.isEnded) {
await onUnlocked()
} else {
uni.showToast({ title: '需要看完广告哦', icon: 'none' })
}
})
videoAd.onError(async () => {
await onUnlocked()
})
await videoAd.show().catch(async () => {
await videoAd.load()
await videoAd.show()
})
return
} catch (e) {
await onUnlocked()
return
}
// #endif
// #ifndef MP-WEIXIN
await onUnlocked()
// #endif
}
async function unlockToday() {
try {
await api.unlockAiAdvice({ date: formatLocalDate() })
} catch (e) {
console.error('unlockToday error:', e)
}
}
async function fetchAISuggestion() {
aiLoading.value = true
try {
const res = await api.getAINextSmokeTime()
const data = res.data || {}
if (!homeData.value) {
homeData.value = {}
}
if (!homeData.value.timer) {
homeData.value.timer = {}
}
homeData.value.timer.suggestion_source = 'ai'
homeData.value.timer.ai_time_nodes = data.time_nodes || []
homeData.value.timer.ai_advice = data.advice || ''
homeData.value.timer.next_suggested_at = data.suggested_at || ''
if (data.suggested_at) {
const t = new Date(data.suggested_at)
if (!isNaN(t.getTime())) {
homeData.value.timer.next_suggested_clock = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
}
}
uni.showToast({ title: 'AI 计划已生成', icon: 'success' })
} catch (e) {
console.error('fetchAISuggestion error:', e)
const msg = e?.data?.message || '生成失败,请稍后重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
aiLoading.value = false
}
}
async function handleAISuggest() {
if (aiLoading.value) return
if (aiTimeNodes.value.length > 0) {
await fetchAISuggestion()
return
}
await runRewardedUnlock(async () => {
await unlockToday()
await fetchAISuggestion()
})
}
onShow(async () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
await waitForLogin()
await fetchHomeData()
} catch (e) {
console.error('ai suggest onShow error:', e)
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
overflow: hidden;
}
.status-bar {
background: transparent;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 80rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.14);
}
.page-glow-b {
top: 320rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.9);
}
.container {
padding: 24rpx 32rpx 80rpx;
position: relative;
z-index: 1;
}
.hero-card,
.section-card,
.advice-card,
.empty-card {
background: rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.hero-card {
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-label {
font-size: 22rpx;
color: #1a7f61;
display: block;
margin-bottom: 12rpx;
}
.hero-title {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
.hero-desc {
font-size: 24rpx;
color: #667085;
line-height: 1.6;
}
.section-card {
padding: 28rpx;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
display: block;
}
.section-subtitle {
font-size: 22rpx;
color: #667085;
display: block;
margin-top: 8rpx;
}
.primary-btn {
padding: 10rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.primary-btn-text {
font-size: 22rpx;
font-weight: 600;
color: #FFFFFF;
}
.suggested-pill {
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.88);
border: 2rpx solid rgba(255, 255, 255, 0.72);
margin-bottom: 24rpx;
}
.suggested-pill-label {
font-size: 22rpx;
color: #1a7f61;
}
.suggested-pill-value {
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.timeline {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8rpx;
margin-bottom: 24rpx;
}
.timeline-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.timeline-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background-color: rgba(152, 162, 179, 0.4);
margin-bottom: 8rpx;
}
.timeline-line {
position: absolute;
top: 10rpx;
left: calc(50% + 18rpx);
right: -50%;
height: 2rpx;
background-color: rgba(152, 162, 179, 0.32);
}
.timeline-time {
font-size: 22rpx;
color: #98A2B3;
}
.timeline-node-past .timeline-dot {
background-color: #9CA3AF;
}
.timeline-node-past .timeline-time {
text-decoration: line-through;
}
.timeline-node-current .timeline-dot {
background-color: #1aa37a;
width: 24rpx;
height: 24rpx;
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.5);
}
.timeline-node-current .timeline-time {
color: #1a7f61;
font-weight: 600;
}
.timeline-node-future .timeline-time {
color: #667085;
}
.advice-card {
padding: 22rpx;
background-color: rgba(247, 249, 252, 0.92);
}
.advice-title {
font-size: 24rpx;
font-weight: 600;
color: #1a7f61;
display: block;
margin-bottom: 8rpx;
}
.advice-text {
font-size: 24rpx;
color: #344054;
line-height: 1.7;
}
.empty-card {
padding: 36rpx 28rpx;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #667085;
line-height: 1.6;
}
</style>