29c8e932ae
消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 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。
336 lines
7.9 KiB
Vue
336 lines
7.9 KiB
Vue
<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>
|