Files
airz-uniapp/pages/home/home.uvue
T

227 lines
16 KiB
Plaintext
Raw Normal View History

2026-09-10 10:57:40 +08:00
<template>
<view class="page" :style="pageStyle()">
<view class="home-header">
<view class="safe" :style="statusStyle(0)"></view>
<view class="home-bar" :style="navigationStyle()">
<view class="home-brand" :style="navigationBrandStyle(104)"><BrandLogo /></view>
<view class="header-actions" :style="navigationActionStyle(104, 2)">
<view class="round-action search-action" @click="go('/packages/content/search/search')">
<view class="action-surface"><image class="header-icon" src="/static/icons/search.svg" mode="aspectFit" /></view>
</view>
<view class="round-action notify action-following" @click="go('/packages/account/messages/messages')">
<view class="action-surface"><image class="header-icon" src="/static/icons/bell.svg" mode="aspectFit" /></view>
<view v-if="hasUnread" class="red-dot"></view>
</view>
</view>
</view>
</view>
<scroll-view direction="vertical" class="home-scroll" :style="contentStyle(104, false)">
2026-09-10 10:57:40 +08:00
<view class="home-content">
<view v-if="banners.length > 0" class="ai-banner" @click="openBanner(banners[0].actionUrl)">
<view class="banner-copy"><text class="banner-title">{{ banners[0].title }}</text><text class="banner-subtitle">{{ banners[0].subtitle }}</text></view>
<image class="robot-img" :src="banners[0].image" mode="aspectFit"></image>
</view>
<view v-else class="ai-banner" @click="go('/packages/finance/assistant/assistant')">
2026-09-10 10:57:40 +08:00
<view class="banner-copy"><text class="banner-title">AI智能评估</text><text class="banner-subtitle">精准匹配融资方案</text><text class="banner-desc">让合适的资金主动找上您</text></view>
<image class="robot-img" src="/static/mobile/images/robot-cutout.png" mode="aspectFit"></image>
2026-09-10 10:57:40 +08:00
</view>
<view class="entry-row">
<view class="finance-entry personal" @click="openEntry('personal')"><view><text class="entry-title">个人信用融资</text><text class="entry-subtitle">个人/小微商户</text></view><image src="/static/mobile/images/personal-building-cutout.png" mode="aspectFit" class="finance-entry-image"></image></view>
<view class="finance-entry enterprise entry-following" @click="openEntry('enterprise')" ><view><text class="entry-title">企业信用融资</text><text class="entry-subtitle">企业/个体工商户</text></view><image src="/static/mobile/images/enterprise-building-cutout.png" mode="aspectFit" class="finance-entry-image"></image></view>
2026-09-10 10:57:40 +08:00
</view>
<view class="shortcut-row">
<view class="shortcut" @click="go('/packages/content/classroom/classroom')"><view class="shortcut-icon"><AppIcon name="file-text" :size="60" /></view><text class="shortcut-label">融资课堂</text></view>
<view class="shortcut" @click="go('/packages/finance/assistant/assistant')"><view class="shortcut-icon"><AppIcon name="sparkles" :size="60" /></view><text class="shortcut-label">AI融资助手</text></view>
<view class="shortcut" @click="go('/packages/finance/entry/entry?type=personal')"><view class="shortcut-icon"><AppIcon name="calculator" :size="60" /></view><text class="shortcut-label">融资测算</text></view>
<view class="shortcut" @click="go('/packages/account/points/points')"><view class="shortcut-icon"><AppIcon name="gift" :size="60" /></view><text class="shortcut-label">邀请有礼</text></view>
2026-09-10 10:57:40 +08:00
</view>
<view class="archive-entry" @click="go('/packages/finance/archive/archive')"><AppIcon name="archive" :size="42" /><text class="archive-label">我的融资档案</text><text class="archive-action">去完善</text><AppIcon name="chevron-right" :size="28" /></view>
<view class="section-head"><text class="section-title">我的融资方案</text><view class="more-link" @click="go('/packages/finance/archive/archive')"><text class="more">查看档案</text><AppIcon name="chevron-right" :size="24" /></view></view>
<view class="plan-card" @click="openSummary">
<view class="plan-main"><text class="plan-name">{{ summaryName() }}</text><text class="plan-meta">{{ summaryMeta() }}</text></view>
<view class="score"><text class="score-number">{{ summaryCount() }}</text><text class="score-label">{{ summaryLabel() }}</text></view>
</view>
<view class="section-head"><text class="section-title">融资课堂</text><view class="more-link" @click="go('/packages/content/classroom/classroom')"><text class="more">进入课堂</text><AppIcon name="chevron-right" :size="24" /></view></view>
<view v-if="contentLoading" class="home-content-state"><text>正在加载课程…</text></view>
<view v-else-if="contentError.length > 0" class="home-content-state" @click="loadRecommendationsRetry"><text>{{ contentError }}</text><text class="home-content-retry">重新加载</text></view>
<view v-else-if="recommendedLessons.length == 0 && recommendedArticles.length == 0" class="home-content-state"><text>暂无推荐内容</text></view>
<view v-else-if="recommendedLessons.length > 0" class="news-preview" @click="go('/packages/content/lesson/lesson?id=' + recommendedLessons[0].id)"><view class="news-copy"><text class="news-title">{{ recommendedLessons[0].title }}</text><text class="news-meta">{{ recommendedLessons[0].tag }} · {{ recommendedLessons[0].duration }}</text></view><image class="news-art" :src="recommendedLessons[0].image" mode="aspectFill"></image></view>
<view v-else class="news-preview" @click="go('/packages/content/article/article?id=' + recommendedArticles[0].id)"><view class="news-copy"><text class="news-title">{{ recommendedArticles[0].title }}</text><text class="news-meta">{{ recommendedArticles[0].category }} · {{ recommendedArticles[0].date }}</text></view><image class="news-art" :src="recommendedArticles[0].image" mode="aspectFill"></image></view>
2026-09-10 10:57:40 +08:00
</view>
</scroll-view>
<BottomNav active="home" />
</view>
</template>
<script setup lang="uts">
import { getSession } from '@/services/api.uts'
import { fetchUnreadMessages } from '@/services/messages.uts'
import { fetchContentHome, FinanceLesson, NewsArticle } from '@/services/content.uts'
import { fetchHome, HomeAssessmentSummary, HomeBanner } from '@/services/home.uts'
import { usePageLayout } from '@/services/layout.uts'
const { contentStyle, statusStyle, pageStyle, navigationStyle, navigationActionStyle, navigationBrandStyle } = usePageLayout()
2026-09-10 10:57:40 +08:00
import BrandLogo from '@/components/BrandLogo.uvue'
import BottomNav from '@/components/BottomNav.uvue'
import AppIcon from '@/components/AppIcon.uvue'
import { consumeInitialLaunch } from '@/services/launch.uts'
onLoad(() => {
if (consumeInitialLaunch()) uni.reLaunch({ url: '/packages/account/index/index' })
})
const hasUnread = ref(false)
const banners = ref<HomeBanner[]>([])
const assessmentSummary = ref<HomeAssessmentSummary | null>(null)
const homeError = ref('')
const homeLoading = ref(false)
const recommendedLessons = ref<FinanceLesson[]>([])
const recommendedArticles = ref<NewsArticle[]>([])
const contentLoading = ref(false); const contentError = ref('')
let generation = 0; let visible = false; let owner = ''
const clearHome = () => {
banners.value = []; assessmentSummary.value = null; hasUnread.value = false
recommendedLessons.value = []; recommendedArticles.value = []
homeError.value = ''; contentError.value = ''; contentLoading.value = false
}
const active = (ticket: number, token: string): boolean => {
if (!visible || ticket != generation) return false
const currentToken = getSession()?.getString('token') ?? ''
if (currentToken == token) return true
generation++; clearHome(); owner = currentToken
return false
}
const applyRecommendations = (lessons: FinanceLesson[], articles: NewsArticle[]) => {
recommendedLessons.value = lessons; recommendedArticles.value = articles; contentError.value = ''
}
2026-09-10 10:57:40 +08:00
const go = (url: string) => { uni.navigateTo({ url: url }) }
async function loadRecommendations(ticket: number = generation, token: string = owner) {
contentLoading.value = true; contentError.value = ''
try {
const result = await fetchContentHome()
if (active(ticket, token)) applyRecommendations(result.lessons, result.articles)
} catch (_) { if (active(ticket, token)) contentError.value = '推荐内容加载失败' }
finally { if (ticket == generation) contentLoading.value = false }
}
async function loadFallback(ticket: number, token: string) {
// 聚合接口失败时只补读公开内容;个人摘要仍保持空值,避免伪造账户数据。
loadRecommendations(ticket, token)
if (token.length == 0) return
try {
const unread = await fetchUnreadMessages()
if (active(ticket, token)) hasUnread.value = unread > 0
} catch (_) { /* 消息页会显示明确错误,首页不把失败当成未读为零。 */ }
}
async function loadHome(retryAfterSessionChange: boolean = true) {
const ticket = ++generation; const token = getSession()?.getString('token') ?? ''
owner = token; clearHome(); homeLoading.value = true
try {
const result = await fetchHome()
if (!active(ticket, token)) return
banners.value = result.banners; assessmentSummary.value = result.assessmentSummary
hasUnread.value = result.unreadCount > 0
applyRecommendations(result.recommendations.lessons, result.recommendations.articles)
} catch (_) {
// 请求过程中会话被共享请求层清除后,以当前会话(通常是匿名)重试,
// 避免旧 token 的错误阻止公开内容回填。
const currentToken = getSession()?.getString('token') ?? ''
if (retryAfterSessionChange && currentToken != token) {
if (visible) await loadHome(false)
return
}
if (!active(ticket, token)) return
homeError.value = '首页聚合数据暂时不可用'
await loadFallback(ticket, token)
} finally { if (ticket == generation) homeLoading.value = false }
}
const loadRecommendationsRetry = () => { loadRecommendations(generation, owner) }
const summaryName = (): string => {
if (homeLoading.value) return '正在读取融资方案'
if (homeError.value.length > 0) return '融资方案暂不可用'
if (assessmentSummary.value == null) return owner.length > 0 ? '暂无融资评估' : '登录后查看融资方案'
const latest = assessmentSummary.value!.latest
return latest == null ? '我的融资评估' : (latest.statusLabel.length > 0 ? latest.statusLabel : '我的融资评估')
}
const summaryMeta = (): string => {
if (homeError.value.length > 0) return '点击重试,或进入消息与档案页面查看'
if (assessmentSummary.value == null) return owner.length > 0 ? '完成一次评估后,这里会显示最新状态' : '登录后可查看自己的评估状态'
const latest = assessmentSummary.value!.latest
if (latest == null) return '已有评估记录,可进入融资档案查看'
return latest.hasReport ? '报告已生成,可查看分析结果' : '进入融资档案查看分析进度'
}
const summaryCount = (): string => assessmentSummary.value == null ? '—' : assessmentSummary.value!.total.toString()
const summaryLabel = (): string => assessmentSummary.value == null ? '评估记录' : '份评估记录'
const openSummary = () => {
const latest = assessmentSummary.value?.latest
if (latest == null) { go('/packages/finance/archive/archive'); return }
if (latest.status == 'succeeded' && latest.hasReport) {
go('/packages/finance/report/report?assessmentId=' + latest.assessmentId + (latest.revisionId != null ? '&revisionId=' + latest.revisionId : ''))
} else go('/packages/finance/archive/archive?assessmentId=' + latest.assessmentId + '&type=' + latest.type)
}
const openBanner = (url: string) => {
if (url.startsWith('/pages/') || url.startsWith('/packages/')) go(url)
}
onShow(() => { visible = true; loadHome() })
onHide(() => { visible = false; generation++; clearHome(); homeLoading.value = false })
onUnload(() => { visible = false; generation++; clearHome() })
const openEntry = (type: string) => { go('/packages/finance/entry/entry?type=' + type) }
2026-09-10 10:57:40 +08:00
</script>
<style>
.home-content-state { padding: 24rpx 0; font-size: 25rpx; line-height: 40rpx; color: #66758a; }
.home-content-retry { color: #0868f7; margin-top: 12rpx; }
.home-header { background-color: #ffffff; }
.safe { }
.home-bar { height: 104rpx; padding: 0 28rpx; flex-direction: row; align-items: center; justify-content: space-between; position: relative; }
.home-brand { flex-shrink: 0; justify-content: center; }
.header-actions { width: 140rpx; height: 62rpx; margin-left: 16rpx; flex-shrink: 0; flex-direction: row; }
.round-action { flex: 1; height: 100%; position: relative; }
.action-following { margin-left: 16rpx; }
.action-surface { width: 100%; height: 100%; align-items: center; justify-content: center; }
.header-icon { width: 36rpx; height: 36rpx; flex-shrink: 0; }
/* 红点独立定位,不影响图标居中及点击区域。 */
.red-dot { position: absolute; width: 16rpx; height: 16rpx; right: 2rpx; top: 2rpx; z-index: 1; background-color: #ff4d4f; border-radius: 50%; border: 2rpx solid #fff; }
.home-scroll { }
.home-content { padding: 18rpx 28rpx 180rpx; }
.ai-banner { height: 246rpx; border-radius: 20rpx; padding: 30rpx; overflow: hidden; flex-direction: row; align-items: center; position: relative; background-color: #1263f0; background-image: linear-gradient(to bottom right, #1263f0, #0788ff); }
.banner-copy { z-index: 2; }
.banner-title { color: #ffffff; font-size: 39rpx; font-weight: 800; }
.banner-subtitle { color: #ffffff; font-size: 30rpx; font-weight: 700; margin-top: 12rpx; }
.banner-desc { color: #dceeff; font-size: 22rpx; margin-top: 14rpx; }
.robot-img { position: absolute; right: 0; width: 240rpx; height: 220rpx; }
.entry-row { flex-direction: row; margin-top: 22rpx; }
.finance-entry { flex: 1; height: 180rpx; border-radius: 18rpx; padding: 26rpx 22rpx; flex-direction: row; overflow: hidden; position: relative; }
.entry-following { margin-left: 18rpx; }
.personal { background-color: #126df1; }
.enterprise { background-color: #ff821c; }
.finance-entry-image { position: absolute; width: 150rpx; height: 135rpx; right: -8rpx; bottom: -8rpx; }
.entry-title { font-size: 29rpx; font-weight: 800; }
.personal .entry-title { color: #ffffff; }
.enterprise .entry-title { color: #ffffff; }
.entry-subtitle { margin-top: 10rpx; color: #ffffff; font-size: 21rpx; }
.shortcut-row { height: 160rpx; margin-top: 24rpx; flex-direction: row; align-items: center; justify-content: space-between; background-color: #fff; border-radius: 18rpx; padding: 0 8rpx; border: 1rpx solid #eaf0f7; }
.shortcut { flex: 1; align-items: center; }
.shortcut-label { font-size: 23rpx; color: #34445c; text-align: center; }
.archive-entry { margin-top: 20rpx; padding: 20rpx 24rpx; flex-direction: row; align-items: center; background-color: #ffffff; border: 1rpx solid #e6edf7; border-radius: 16rpx; }
.archive-label { flex: 1; margin-left: 16rpx; font-size: 27rpx; font-weight: 600; color: #243650; }
.archive-action { color: #0868f7; font-size: 23rpx; margin-right: 8rpx; }
.shortcut-icon { width: 76rpx; height: 76rpx; border-radius: 24rpx; align-items: center; justify-content: center; margin-bottom: 10rpx; }
.green { background-color: #e7f8f1; }
.purple { background-color: #efebff; }
.blue { background-color: #e8f2ff; }
.orange { background-color: #fff0e8; }
.section-head { margin-top: 34rpx; margin-bottom: 18rpx; flex-direction: row; justify-content: space-between; align-items: center; }
.more { color: #1673ed; font-size: 22rpx; }
.more-link { flex-direction: row; align-items: center; }
.plan-card { height: 150rpx; background-color: #fff; border: 1rpx solid #e6edf7; border-radius: 16rpx; padding: 26rpx; flex-direction: row; align-items: center; justify-content: space-between; }
.plan-name { font-size: 29rpx; font-weight: 700; }
.plan-meta { margin-top: 12rpx; color: #8290a5; font-size: 22rpx; }
.score { align-items: center; }
.score-label { color: #718198; font-size: 21rpx; }
.score-number { font-size: 48rpx; line-height: 52rpx; color: #0868f7; font-weight: 800; }
.news-preview { min-height: 130rpx; background-color: #fff; border-radius: 16rpx; padding: 24rpx; flex-direction: row; justify-content: space-between; align-items: center; border: 1rpx solid #e6edf7; }
.news-copy { flex: 1; padding-right: 16rpx; }
.news-title { font-size: 27rpx; font-weight: 700; }
.news-meta { margin-top: 14rpx; font-size: 21rpx; color: #94a0b1; }
.news-art { width: 118rpx; height: 82rpx; border-radius: 10rpx; flex-shrink: 0; }
2026-09-10 10:57:40 +08:00
</style>