Files
airz-uniapp/packages/account/messages/messages.uvue
hujun 1723363b7e feat: 增加反馈消息闭环相关功能及文件
新增反馈目标登录白名单、账户切换守卫、待确认反馈存储、缓存生命周期管理,补充相关文档及截图,完善消息跳转逻辑
2026-10-05 20:11:24 +08:00

150 lines
12 KiB
Plaintext

<template>
<view class="page" :style="pageStyle()">
<AppHeader title="消息中心" :back="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">{{ 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 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 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" />
</view>
</template>
<script setup lang="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()
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; let owner = ''
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 }
if (append && token != owner) { generation++; reset(); reloadAfterAccountChange(); return }
owner = token
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
if (!current(generation, owner)) 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
if (!current(generation, owner)) 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>
.message-tabs { height: 88rpx; flex-direction: row; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; padding: 0 18rpx; }
.message-tab { flex: 1; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.active-tab { border-bottom-color: #0868f7; }
.tab-text { color: #66758a; font-size: 25rpx; }
.active-text { color: #0868f7; font-weight: 700; }
.messages-content { padding: 14rpx 28rpx 90rpx; }
.message-summary { flex-direction: row; align-items: center; justify-content: space-between; min-height: 66rpx; }
.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; }
.notice-icon { width: 76rpx; height: 76rpx; margin-right: 20rpx; position: relative; flex-shrink: 0; }
.notice-icon-surface { width: 100%; height: 100%; border-radius: 20rpx; background-color: #edf5ff; align-items: center; justify-content: center; }
.notice-icon-payout .notice-icon-surface { background-color: #eaf7f3; }
.notice-icon-gift .notice-icon-surface { background-color: #fff3e8; }
.notice-symbol { width: 46rpx; height: 46rpx; flex-shrink: 0; }
.notice-copy { flex: 1; min-width: 0; }
.notice-head { flex-direction: row; justify-content: space-between; align-items: flex-start; }
.notice-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; flex: 1; }
.notice-time { color: #66758a; font-size: 22rpx; line-height: 40rpx; margin-left: 12rpx; flex-shrink: 0; }
.notice-desc { color: #66758a; font-size: 25rpx; line-height: 38rpx; margin-top: 10rpx; }
.notice-category { color: #66758a; font-size: 22rpx; line-height: 32rpx; margin-top: 10rpx; }
/* 红点独立于圆角背景,避免跨端圆角裁切。 */
.unread { position: absolute; width: 15rpx; height: 15rpx; border-radius: 8rpx; background-color: #f04452; border: 2rpx solid #ffffff; right: -3rpx; top: -3rpx; z-index: 1; }
.message-footer { color: #66758a; font-size: 22rpx; text-align: center; margin-top: 28rpx; }
</style>