feat: 接入内容服务端真实接口并优化页面交互

- 迁移内容数据到服务端接口,移除本地模拟数据
- 首页新增推荐课程加载与未读消息检查
- 资讯、课程、搜索页面支持分页加载与错误重试
- 登录页新增恢复课程、消息、帮助页的路由逻辑
- 评估分析页新增处理中状态动效与阶段提示
- API 环境切换到正式域名 airz.hqling.cloud
This commit is contained in:
hujun
2026-10-01 20:26:41 +08:00
parent 3b2a0139e4
commit 2a9ff403c4
41 changed files with 21742 additions and 427 deletions
+96 -38
View File
@@ -1,17 +1,24 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="消息中心" :back="false" />
<view class="message-tabs"><view v-for="item in tabs" :key="item" :class="tab == item ? 'message-tab active-tab' : 'message-tab'" @click="tab = item"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view></view>
<scroll-view scroll-y :style="contentStyle(314, false)">
<view class="message-tabs"><view v-for="item in tabs" :key="item.type" :class="tab == item.type ? 'message-tab active-tab' : 'message-tab'" @click="changeTab(item.type)"><text :class="tab == item.type ? 'tab-text active-text' : 'tab-text'">{{ item.label }}</text></view></view>
<!-- 扣除顶栏96、分类88和底栏130,避免列表末端落到固定导航下。 -->
<scroll-view scroll-y :style="contentStyle(314, false)" @scrolltolower="autoMore">
<view class="messages-content">
<view class="message-summary"><text class="summary-text">{{ unreadCount > 0 ? unreadCount + ' 条未读消息' : '消息已全部阅读' }}</text><view class="read-all" @click="markAllRead"><text class="read-all-text">全部已读</text></view></view>
<view class="message-summary"><text class="summary-text">{{ loading ? '正在加载消息…' : (unreadCount > 0 ? unreadCount + ' 条未读消息' : (notices.length > 0 ? '消息已全部阅读' : '消息中心')) }}</text><view class="read-all" @click="markAllRead"><text :class="marking || loading || snapshotId == '0' || snapshotId.length == 0 ? 'read-all-text disabled-text' : 'read-all-text'">{{ marking ? '正在标记…' : '全部已读' }}</text></view></view>
<view v-if="error.length > 0" class="message-state"><text class="state-title">{{ error }}</text><text class="state-action" @click="reload">重新加载</text></view>
<view v-if="!loading && error.length == 0 && notices.length == 0" class="message-state"><text class="state-title">{{ tab == 'all' ? '暂无消息' : '暂无' + categoryLabel }}</text><text class="state-note">{{ tab == 'all' ? '评估状态更新后,会在这里通知您' : '当前分类暂无消息' }}</text></view>
<text v-if="actionError.length > 0" class="action-error">{{ actionError }}</text>
<view class="notice-list">
<view class="notice" v-for="(item, index) in filteredNotices" :key="item.id" :class="index == filteredNotices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="item.unread" class="unread"></view></view>
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.desc }}</text><text class="notice-category">{{ item.category }}</text></view>
<view class="notice" v-for="(item, index) in notices" :key="item.id" :class="index == notices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="!item.isRead" class="unread"></view></view>
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.content }}</text><text class="notice-category">{{ opening == item.id ? '正在打开…' : item.category }}</text></view>
</view>
</view>
<text class="message-footer">点击消息查看完整内容</text>
<text v-if="paginationError.length > 0" class="action-error">{{ paginationError }}</text>
<text v-if="loadingMore" class="message-footer">正在加载更多…</text>
<text v-else-if="nextBeforeId.length > 0" class="message-footer state-action" @click="more">加载更多</text>
<text v-else-if="notices.length > 0" class="message-footer">已显示全部消息</text>
</view>
</scroll-view>
<BottomNav active="message" />
@@ -19,41 +26,86 @@
</template>
<script setup lang="uts">
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
import { getSession } from '@/services/api.uts'
import { UserMessage, fetchMessages, fetchMessage, readMessage, readAllMessages } from '@/services/messages.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue'
const { contentStyle, pageStyle } = usePageLayout()
const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息']
const tab = ref('全部')
type Notice = { id: string, icon: string, category: string, title: string, desc: string, body: string, time: string, unread: boolean }
const notices = ref<Notice[]>([
{ id: 'assessment-guide', icon: 'approval', category: '服务通知', title: '融资评估使用指南', desc: '完成资料填写,了解自己的融资需求。', body: '从首页选择个人或企业融资入口,按步骤填写基本信息、经营或收入、资产与负债情况。提交前请核对资料,评估结果仅供参考,具体申请条件以机构审核为准。', time: '10:30', unread: true },
{ id: 'repayment-guide', icon: 'payout', category: '服务通知', title: '还款安排提醒', desc: '申请前,记得核对每期应还金额与日期。', body: '比较融资方案时,除利率外还应核对还款方式、每期应还金额与到期本金。正式签约后,请以机构提供的合同和还款计划为准,通过官方渠道查询账户状态。', time: '09:15', unread: false },
{ id: 'invite-guide', icon: 'gift', category: '活动消息', title: '邀请积分说明', desc: '邀请前先了解积分规则与适用范围。', body: '可在积分中心查看邀请入口与积分说明。积分发放条件、有效期及可用权益以活动页面公布的规则为准,请勿向他人承诺固定收益或贷款结果。', time: '08:42', unread: false },
{ id: 'privacy-guide', icon: 'system-bell', category: '系统通知', title: '个人信息保护提醒', desc: '留意授权范围,保护账号与验证码。', body: '提交资料或授权数据前,请阅读相应说明,确认使用目的、数据范围和授权期限。不要向他人泄露账户密码、短信验证码或银行支付信息。', time: '昨天', unread: false }
])
const filteredNotices = computed((): Notice[] => tab.value == '全部' ? notices.value : notices.value.filter((item: Notice): boolean => item.category == tab.value))
const unreadCount = computed((): number => notices.value.filter((item: Notice): boolean => item.unread).length)
onShow(() => {
notices.value.forEach((item: Notice): void => {
if (getUserStorage('finance.notice.read.' + item.id) == true) item.unread = false
})
})
const markRead = (id: string): void => {
const item = notices.value.find((value: Notice): boolean => value.id == id)
if (item == null) return
item.unread = false
setUserStorage('finance.notice.read.' + id, true)
}
const openNotice = (item: Notice): void => {
markRead(item.id)
uni.showModal({ title: item.title, content: item.body, showCancel: false, confirmText: '知道了' })
}
const markAllRead = (): void => {
notices.value.forEach((item: Notice): void => { markRead(item.id) })
}
type MessageTab = { type: string, label: string }
const tabs: MessageTab[] = [{ type: 'all', label: '全部' }, { type: 'system', label: '系统通知' }, { type: 'service', label: '服务通知' }, { type: 'activity', label: '活动消息' }]
const tab = ref('all'); const notices = ref<UserMessage[]>([]); const unreadCount = ref(0)
const categoryLabel = computed((): string => tabs.find((item: MessageTab): boolean => item.type == tab.value)?.label ?? '')
const snapshotId = ref(''); const nextBeforeId = ref(''); const loading = ref(false); const loadingMore = ref(false)
const marking = ref(false); const opening = ref(''); const error = ref(''); const actionError = ref(''); const paginationError = ref('')
let visible = false; let generation = 0
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMessages=1' }) }
const reset = () => { notices.value = []; unreadCount.value = 0; snapshotId.value = ''; nextBeforeId.value = ''; error.value = ''; actionError.value = ''; paginationError.value = ''; marking.value = false; opening.value = ''; loading.value = false; loadingMore.value = false }
// UTS原生端不支持局部函数前向引用,账户切换回调在加载函数声明后绑定。
let reloadAfterAccountChange: () => void = () => {}
// 离页、换分类和账户切换后,旧请求不能恢复上一账户的消息或触发业务跳转。
function current(request: number, token: string): boolean {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; reset()
if (getSession() == null) login(); else reloadAfterAccountChange()
return false
}
async function load(append: boolean) {
if (!visible || loading.value || loadingMore.value || marking.value || opening.value.length > 0) return
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) { reset(); login(); return }
const request = append ? generation : ++generation
if (append) { if (nextBeforeId.value.length == 0) return; loadingMore.value = true; paginationError.value = '' }
else { reset(); loading.value = true }
try {
const result = await fetchMessages(tab.value, append ? nextBeforeId.value : '', append ? snapshotId.value : '')
if (!current(request, token)) return
notices.value = append ? notices.value.concat(result.items) : result.items
snapshotId.value = result.snapshotId; nextBeforeId.value = result.nextBeforeId; unreadCount.value = result.unreadCount
} catch (err) {
if (current(request, token)) {
const message = err instanceof Error ? err.message : '消息加载失败,请重试'
if (append) paginationError.value = message; else error.value = message
}
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
}
function reload() { load(false) }
reloadAfterAccountChange = reload
const more = () => { load(true) }
// 自动触底失败后保留提示,由用户手动重试,避免滚动事件连续覆盖错误。
const autoMore = () => { if (paginationError.value.length == 0) more() }
const changeTab = (type: string) => { if (type == tab.value || marking.value || opening.value.length > 0) return; generation++; reset(); tab.value = type; reload() }
const openNotice = async (item: UserMessage) => {
if (opening.value.length > 0 || marking.value || loading.value || loadingMore.value) return
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
const request = generation; opening.value = item.id; actionError.value = ''
try {
const detail = await fetchMessage(item.id)
if (!current(request, token)) return
const unread = await readMessage(item.id)
if (!current(request, token)) return
item.isRead = true; unreadCount.value = unread
uni.showModal({ title: detail.item.title, content: detail.item.content + (detail.targetUnavailable ? '\n关联评估已不可用,可从融资档案查看其他记录。' : ''),
showCancel: detail.targetUrl.length > 0, cancelText: '关闭', confirmText: detail.targetLabel.length > 0 ? detail.targetLabel : '知道了',
success: (result) => { if (result.confirm && detail.targetUrl.length > 0 && current(request, token)) uni.navigateTo({ url: detail.targetUrl }) } })
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '消息读取失败,请重试' }
finally { if (request == generation) opening.value = '' }
}
const markAllRead = async () => {
if (marking.value || opening.value.length > 0 || loading.value || loadingMore.value || snapshotId.value.length == 0 || snapshotId.value == '0') return
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
const request = generation; marking.value = true; actionError.value = ''
try {
const unread = await readAllMessages(snapshotId.value)
if (!current(request, token)) return
notices.value.forEach((item: UserMessage) => { item.isRead = true }); unreadCount.value = unread
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '标记失败,请重试' }
finally { if (request == generation) marking.value = false }
}
onShow(() => { visible = true; generation++; reset(); reload() })
onHide(() => { visible = false; generation++; reset() })
onUnload(() => { visible = false; generation++; reset() })
</script>
<style>
@@ -67,6 +119,12 @@
.summary-text { color: #66758a; font-size: 23rpx; }
.read-all { padding: 14rpx 0 14rpx 24rpx; }
.read-all-text { color: #0868f7; font-size: 23rpx; }
.disabled-text { color: #a1adbd; }
.message-state { padding: 56rpx 18rpx; align-items: center; }
.state-title { color: #52657e; font-size: 27rpx; text-align: center; line-height: 42rpx; }
.state-note { color: #8291a5; font-size: 23rpx; margin-top: 18rpx; text-align: center; line-height: 36rpx; }
.state-action { color: #0868f7; font-size: 25rpx; padding: 18rpx; }
.action-error { color: #b64d35; font-size: 23rpx; line-height: 36rpx; padding: 16rpx 0; }
.notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; }
.notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: center; }
.last-notice { border-bottom-width: 0; }