Files
xiaotutongxue/frontend-mp/src/pages/messages/messages.vue
T
lovife 29c8e932ae feat(notify): 消息中心、催报、通知模板与微信回调 + 校验问题清单(V8 / V14)
消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 bizType 去重 24 小时);通知降级链只做能真正走通的两级(订阅消息 → 站内信),
公众号模板与短信如实少做并写明理由:永远失败的通道会把真正的原因淹掉。

校验问题清单对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决;
上传时把「系统改过文件名」记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据。

微信回调必须偏离两条全局约定(匿名入口、响应体是 {"errcode":0}):只写推送状态不读
业务数据 + 必须验签 + 凭发出时记的 id 反查租户(查不到就报错,绝不猜一个租户去写)。

验证:e2e-m5-notify 48/48、e2e-m5-urge 40/40、e2e-m7-validation 70/70。
2026-09-30 09:02:53 +08:00

336 lines
7.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="msg">
<view class="msg__wrap">
<view class="msg__bar">
<text class="msg__count">
{{ unread ? `${unread} 条未读` : '没有未读消息' }}
</text>
<text v-if="unread" class="msg__read-all" @click="readAll">全部标为已读</text>
</view>
<!-- 筛选:学生只关心"老师让我改的"和"我过了没有" -->
<view class="msg__tabs">
<text v-for="t in tabs" :key="String(t.value)"
class="msg__tab" :class="{ 'msg__tab--on': bizType === t.value }"
@click="pickBiz(t.value)">{{ t.label }}</text>
</view>
<view v-if="loading" class="msg__empty">正在读取…</view>
<view v-else-if="!list.length" class="msg__empty">
<text>还没有消息。</text>
<text class="msg__empty-hint">
材料被退回、审核通过、临近截止时,这里都会有记录。
</text>
</view>
<view v-for="m in list" :key="m.id" class="msg__card"
:class="{ 'msg__card--unread': !m.read }" @click="open(m)">
<view class="msg__head">
<text class="msg__dot" :class="dotClass(m.bizType)">{{ bizText(m.bizType) }}</text>
<text class="msg__time">{{ fmt(m.createTime) }}</text>
</view>
<text class="msg__title">{{ m.title }}</text>
<text v-if="m.content" class="msg__content">{{ m.content }}</text>
<view v-if="m.linkType && m.linkType !== 'NONE'" class="msg__link">
{{ m.linkType === 'SUBMISSION' ? '查看这份填报 ›' : '打开任务 ›' }}
</view>
</view>
<view class="msg__foot">
一期的推送渠道只有微信订阅消息(一次性授权),拿不到授权时消息**只在这里**,
请偶尔进来看看。
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 消息中心(《03-接口设计》§13.5,学生端)。
*
* ## 为什么这一屏是"通知"的最后一环
*
* 一期的推送渠道只有微信订阅消息,而它是**一次性授权**(用户每点一次「允许」只能发一条)。
* 也就是说:只要学生没授权,或授权用完了,**推送一定发不出去**。
* 那时唯一能看到消息的地方就是这里 —— 所以服务端**每次都写一条站内信**,
* 不管推送成没成功。
*
* 页面底部那句话是刻意留的:让学生知道"别只等推送"。
* 不说的话,没收到推送的人会以为"什么事都没发生"。
*/
defineOptions({ name: 'MyMessages' })
interface Message {
id: number
bizType: number
title: string
content?: string
read: boolean
linkType?: string
linkId?: number
channel?: string
createTime?: string
}
const loading = ref(false)
const list = ref<Message[]>([])
const unread = ref(0)
const bizType = ref<number | undefined>(undefined)
const tabs: Array<{ label: string, value: number | undefined }> = [
{ label: '全部', value: undefined },
{ label: '退回', value: 3 },
{ label: '通过', value: 4 },
{ label: '提醒', value: 2 }
]
const unreadText = computed(() => (unread.value ? `${unread.value} 条未读` : '没有未读消息'))
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : ''
}
/** 业务类型 → 角标文字(与后端 CollectMessage 的码一致)。 */
function bizText(t: number) {
switch (t) {
case 1: return '任务'
case 2: return '提醒'
case 3: return '退回'
case 4: return '通过'
case 5: return '催报'
case 6: return '导出'
case 7: return '审批'
default: return '消息'
}
}
function dotClass(t: number) {
if (t === 3) return 'msg__dot--bad'
if (t === 4) return 'msg__dot--ok'
return 'msg__dot--info'
}
async function load() {
loading.value = true
try {
const q = bizType.value === undefined ? '' : `&bizType=${bizType.value}`
const d = await request<{ list: Message[], total: number, unreadCount: number }>({
url: `/api/v1/mp/messages?pageSize=50${q}`,
token: token()
})
list.value = d.list || []
unread.value = d.unreadCount || 0
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function pickBiz(v: number | undefined) {
bizType.value = v
load()
}
async function readAll() {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { all: true }
})
unread.value = d?.unreadCount ?? 0
list.value = list.value.map((m) => ({ ...m, read: true }))
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '操作失败', icon: 'none' })
}
}
/** 点开一条:标已读 + 跳到对应页面。 */
async function open(m: Message) {
if (!m.read) {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { messageIds: [m.id] }
})
unread.value = d?.unreadCount ?? unread.value
m.read = true
} catch {
// 标已读失败不拦跳转:用户点这条消息的目的是去看那件事
}
}
if (m.linkType === 'SUBMISSION' && m.linkId) {
// 填报单详情 → 进度页(那里有材料清单、问题与撤回)
uni.navigateTo({ url: `/pages/progress/progress?submissionId=${m.linkId}` })
} else if (m.linkType === 'TASK' && m.linkId) {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${m.linkId}` })
}
}
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.msg {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4rpx 16rpx;
}
&__count {
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__read-all {
font-size: 25rpx;
color: $primary;
}
&__tabs {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
&__tab {
padding: 8rpx 24rpx;
font-size: 25rpx;
color: #4a5768;
background: #fff;
border-radius: 28rpx;
&--on {
color: #fff;
background: $primary;
}
}
&__empty {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 48rpx 28rpx;
font-size: 27rpx;
line-height: 1.7;
color: #4a5768;
background: #fff;
border-radius: 16rpx;
}
&__empty-hint {
font-size: 24rpx;
color: #8b95a3;
}
&__card {
padding: 24rpx 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
&--unread {
border-left: 8rpx solid $primary;
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
&__dot {
padding: 4rpx 14rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--bad {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--info {
color: $primary;
background: #eaf1fd;
}
}
&__time {
font-size: 22rpx;
color: #b3b9c1;
}
&__title {
display: block;
font-size: 29rpx;
font-weight: 600;
color: #1b2430;
}
&__content {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
}
&__link {
margin-top: 12rpx;
font-size: 25rpx;
color: $primary;
}
&__foot {
margin-top: 32rpx;
font-size: 23rpx;
line-height: 1.8;
color: #b3b9c1;
}
}
</style>