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。
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user