feat:修改详细页面,修改为每个类型的页面不对
This commit is contained in:
parent
18aff3b682
commit
99775f1d11
@ -2,21 +2,36 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dubbo.apache.org/dubbo-go/v3/client"
|
"dubbo.apache.org/dubbo-go/v3/client"
|
||||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
pbNotif "github.com/topfans/backend/pkg/proto/notification"
|
|
||||||
"github.com/topfans/backend/gateway/pkg/response"
|
"github.com/topfans/backend/gateway/pkg/response"
|
||||||
|
"github.com/topfans/backend/pkg/database"
|
||||||
|
pbNotif "github.com/topfans/backend/pkg/proto/notification"
|
||||||
"github.com/topfans/backend/pkg/logger"
|
"github.com/topfans/backend/pkg/logger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notifNameCache 按 "userID:starID" 缓存 (mobile, nickname, star_name) 拉取结果,
|
||||||
|
// 避免列表页对同一 (user, star) 组合重复打 3 次 SELECT。
|
||||||
|
// 用 sync.Map 是因为 controller 是高并发 handler 调用,且原写法 (StatsMap 等) 同模式。
|
||||||
|
type notifNameEntry struct {
|
||||||
|
mobile string
|
||||||
|
nickname string
|
||||||
|
starName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifNameCache sync.Map // key: string ("userID:starID") -> notifNameEntry
|
||||||
|
|
||||||
// NotificationController 通知相关控制器
|
// NotificationController 通知相关控制器
|
||||||
type NotificationController struct {
|
type NotificationController struct {
|
||||||
notifService pbNotif.NotificationService
|
notifService pbNotif.NotificationService
|
||||||
@ -214,9 +229,10 @@ func (ctrl *NotificationController) GetNotifications(g *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换为 map 列表(Notification 含 structpb.Struct 序列化友好)
|
// 转换为 map 列表(Notification 含 structpb.Struct 序列化友好)
|
||||||
|
db := database.GetDB()
|
||||||
items := make([]map[string]interface{}, 0, len(resp.Items))
|
items := make([]map[string]interface{}, 0, len(resp.Items))
|
||||||
for _, n := range resp.Items {
|
for _, n := range resp.Items {
|
||||||
items = append(items, convertNotification(n))
|
items = append(items, convertNotification(db, n))
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(g, gin.H{
|
response.Success(g, gin.H{
|
||||||
@ -584,30 +600,34 @@ func (ctrl *NotificationController) DeleteByTarget(g *gin.Context) {
|
|||||||
|
|
||||||
// convertNotification 将 *pbNotif.Notification 转为前端友好的 map
|
// convertNotification 将 *pbNotif.Notification 转为前端友好的 map
|
||||||
// (proto 序列化时 structpb.Struct 不友好, 转成 map[string]interface{})
|
// (proto 序列化时 structpb.Struct 不友好, 转成 map[string]interface{})
|
||||||
func convertNotification(n *pbNotif.Notification) map[string]interface{} {
|
// 同时按 (user_id, star_id) 补上 user_mobile / user_nickname / star_name 三项,
|
||||||
|
// 注入到 data map 里,Postcard 渲染时直接从 data 取。
|
||||||
|
func convertNotification(db *gorm.DB, n *pbNotif.Notification) map[string]interface{} {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
item := map[string]interface{}{
|
item := map[string]interface{}{
|
||||||
"id": n.Id,
|
"id": n.Id,
|
||||||
"user_id": n.UserId,
|
"user_id": n.UserId,
|
||||||
"star_id": n.StarId,
|
"star_id": n.StarId,
|
||||||
"type": n.Type,
|
"type": n.Type,
|
||||||
"title": n.Title,
|
"title": n.Title,
|
||||||
"content": n.Content,
|
"content": n.Content,
|
||||||
"is_read": n.IsRead,
|
"is_read": n.IsRead,
|
||||||
"created_at": n.CreatedAt,
|
"created_at": n.CreatedAt,
|
||||||
"read_at": n.ReadAt,
|
"read_at": n.ReadAt,
|
||||||
"target_id": n.TargetId,
|
"target_id": n.TargetId,
|
||||||
"aggregated": n.Aggregated,
|
"aggregated": n.Aggregated,
|
||||||
"total_count": n.TotalCount,
|
"total_count": n.TotalCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
// data 字段: *structpb.Struct → map
|
// data 字段: *structpb.Struct → map;旧端 key 全部保留,只追加新 key。
|
||||||
|
data := map[string]interface{}{}
|
||||||
if n.Data != nil {
|
if n.Data != nil {
|
||||||
item["data"] = n.Data.AsMap()
|
data = n.Data.AsMap()
|
||||||
}
|
}
|
||||||
|
item["data"] = data
|
||||||
|
|
||||||
// actors 字段
|
// actors 字段
|
||||||
if len(n.Actors) > 0 {
|
if len(n.Actors) > 0 {
|
||||||
@ -626,9 +646,78 @@ func convertNotification(n *pbNotif.Notification) map[string]interface{} {
|
|||||||
item["actors"] = actors
|
item["actors"] = actors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 附加 user_mobile / user_nickname / star_name (DB join, 带缓存)
|
||||||
|
if db != nil && n.UserId > 0 {
|
||||||
|
mobile, nickname, starName := lookupNames(db, n.UserId, n.StarId)
|
||||||
|
if mobile != "" {
|
||||||
|
data["user_mobile"] = mobile
|
||||||
|
}
|
||||||
|
if nickname != "" {
|
||||||
|
data["user_nickname"] = nickname
|
||||||
|
}
|
||||||
|
if starName != "" {
|
||||||
|
data["star_name"] = starName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskMobile 把 11 位手机号中间 4 位脱敏 -> "139****0001"。长度不足返回原值。
|
||||||
|
func maskMobile(m string) string {
|
||||||
|
if len(m) < 7 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return m[:3] + "****" + m[len(m)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupNames 查 (user_id, star_id) 对应的 masked_mobile / nickname / star_name,
|
||||||
|
// 命中 sync.Map 缓存的 (userID:starID) 键直接返回,未命中走 3 次轻量 SELECT。
|
||||||
|
// 任意一次失败 (record not found / db nil) 都返回空串,不阻断主流程。
|
||||||
|
func lookupNames(db *gorm.DB, userID, starID int64) (mobile, nickname, starName string) {
|
||||||
|
if db == nil || userID <= 0 {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d:%d", userID, starID)
|
||||||
|
if v, ok := notifNameCache.Load(key); ok {
|
||||||
|
entry, _ := v.(notifNameEntry)
|
||||||
|
return entry.mobile, entry.nickname, entry.starName
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRow struct {
|
||||||
|
Mobile string `gorm:"column:mobile"`
|
||||||
|
}
|
||||||
|
var ur userRow
|
||||||
|
if err := db.Table("users").Select("mobile").Where("id = ?", userID).Take(&ur).Error; err == nil {
|
||||||
|
mobile = maskMobile(ur.Mobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fan_profiles 是 (user_id, star_id) 联合唯一键;nickname 是同 star 下相对唯一的 ID 化昵称。
|
||||||
|
type fpRow struct {
|
||||||
|
Nickname string `gorm:"column:nickname"`
|
||||||
|
}
|
||||||
|
var fpr fpRow
|
||||||
|
if starID > 0 {
|
||||||
|
if err := db.Table("fan_profiles").Select("nickname").
|
||||||
|
Where("user_id = ? AND star_id = ?", userID, starID).Take(&fpr).Error; err == nil {
|
||||||
|
nickname = fpr.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if starID > 0 {
|
||||||
|
type starRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var sr starRow
|
||||||
|
if err := db.Table("stars").Select("name").Where("star_id = ?", starID).Take(&sr).Error; err == nil {
|
||||||
|
starName = sr.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifNameCache.Store(key, notifNameEntry{mobile: mobile, nickname: nickname, starName: starName})
|
||||||
|
return mobile, nickname, starName
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Admin 入口(无鉴权,内网部署) ==========
|
// ========== Admin 入口(无鉴权,内网部署) ==========
|
||||||
//
|
//
|
||||||
// AdminCreateNotification 供 Python admin (8081) 调用的批量发送入口。
|
// AdminCreateNotification 供 Python admin (8081) 调用的批量发送入口。
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"appid" : "__UNI__F199FF4",
|
"appid" : "__UNI__F199FF4",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.5",
|
"versionName" : "1.0.5",
|
||||||
"versionCode" : 118,
|
"versionCode" : 119,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
@ -106,7 +106,7 @@
|
|||||||
// "share" : {
|
// "share" : {
|
||||||
// "weixin" : {
|
// "weixin" : {
|
||||||
// "appid" : "<REPLACE_WITH_WEIXIN_APPID>",
|
// "appid" : "<REPLACE_WITH_WEIXIN_APPID>",
|
||||||
// "UniversalLinks" : "https://topfans.online/share/"
|
// "UniversalLinks" : "https://topfans.online/uni-universallinks/"
|
||||||
// },
|
// },
|
||||||
// "qq" : {
|
// "qq" : {
|
||||||
// "appid" : "<REPLACE_WITH_QQ_APPID>"
|
// "appid" : "<REPLACE_WITH_QQ_APPID>"
|
||||||
|
|||||||
@ -397,7 +397,8 @@
|
|||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"app-plus": {
|
"app-plus": {
|
||||||
"bounce": "none"
|
"bounce": "none"
|
||||||
}
|
},
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,124 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="postcard">
|
<component
|
||||||
<view class="postcard-header">
|
:is="cardType"
|
||||||
<text class="postcard-title">{{ notification.title }}</text>
|
:notification="notification"
|
||||||
<view class="postcard-meta">
|
:displayTitle="displayTitle"
|
||||||
<text v-if="notification.type" class="meta-type">[{{ typeLabel }}]</text>
|
@mark-read="emit('mark-read', $event)"
|
||||||
<text class="meta-time">{{ formatTime(notification.created_at) }}</text>
|
@delete="emit('delete', $event)"
|
||||||
</view>
|
/>
|
||||||
</view>
|
|
||||||
<view class="postcard-content">
|
|
||||||
<text>{{ notification.content }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="businessMeta.length" class="postcard-extra">
|
|
||||||
<view v-for="m in businessMeta" :key="m.k" class="meta-row">
|
|
||||||
<text class="meta-key">{{ m.k }}:</text>
|
|
||||||
<text class="meta-val">{{ m.v }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-if="!notification.is_read" class="postcard-unread-tip">
|
|
||||||
<text>● 未读</text>
|
|
||||||
</view>
|
|
||||||
<view class="postcard-toolbar">
|
|
||||||
<view v-if="!notification.is_read" class="toolbar-btn toolbar-btn-read" @click="$emit('mark-read', notification)">
|
|
||||||
<text>✓ 标已读</text>
|
|
||||||
</view>
|
|
||||||
<view class="toolbar-btn toolbar-btn-delete" @click="$emit('delete', notification)">
|
|
||||||
<text>🗑 删除</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
const props = defineProps({ notification: { type: Object, required: true } })
|
import PostcardBase from './PostcardBase.vue'
|
||||||
defineEmits(['mark-read', 'delete'])
|
import PostcardFeedback from './PostcardFeedback.vue'
|
||||||
|
import PostcardReport from './PostcardReport.vue'
|
||||||
|
import PostcardTarget from './PostcardTarget.vue'
|
||||||
|
import PostcardActivity from './PostcardActivity.vue'
|
||||||
|
import { parseData } from './postcardHelpers.js'
|
||||||
|
|
||||||
const typeMap = {
|
const props = defineProps({
|
||||||
feedback_replied: '反馈回复',
|
notification: { type: Object, required: true },
|
||||||
report_resolved: '举报结果',
|
|
||||||
target_reported: '被举报',
|
|
||||||
activity: '活动通知',
|
|
||||||
}
|
|
||||||
const typeLabel = computed(() => typeMap[props.notification.type] || props.notification.type || '通知')
|
|
||||||
|
|
||||||
const businessMeta = computed(() => {
|
|
||||||
// data 可能是 string(JSON) 或 object(proto structpb.AsMap() 的产物)。
|
|
||||||
// P0-1 fix:网关 convertNotification 走 AsMap() 直接发对象,旧写法 JSON.parse(obj) 会 SyntaxError → {}。
|
|
||||||
let data = {}
|
|
||||||
const raw = props.notification.data
|
|
||||||
if (raw) {
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
try { data = JSON.parse(raw) } catch (e) { data = {} }
|
|
||||||
} else if (typeof raw === 'object') {
|
|
||||||
data = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const list = []
|
|
||||||
if (data.feedback_id) list.push({ k: '反馈 ID', v: data.feedback_id })
|
|
||||||
if (data.report_id) list.push({ k: '举报 ID', v: data.report_id })
|
|
||||||
// P1-1 fix:admin backend moderation_admin.py 用 action(规范名),不是 resolved_action
|
|
||||||
if (data.action) list.push({ k: '处理动作', v: actionLabel(data.action) })
|
|
||||||
if (data.category_code) list.push({ k: '分类', v: data.category_code })
|
|
||||||
if (data.original_title) list.push({ k: '原标题', v: data.original_title })
|
|
||||||
if (data.activity_id) list.push({ k: '活动 ID', v: data.activity_id })
|
|
||||||
return list
|
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
function actionLabel(a) {
|
// 按 notification.type 选择类型专属卡片;未知类型回退到 PostcardBase(只显示 chrome)。
|
||||||
return { takedown: '已下架', ban: '已封禁', warn: '已警告', dismiss: '已驳回', restore: '已解除' }[a] || a
|
const cardMap = {
|
||||||
}
|
feedback_replied: PostcardFeedback,
|
||||||
function formatTime(ms) {
|
report_resolved: PostcardReport,
|
||||||
if (!ms) return ''
|
target_reported: PostcardTarget,
|
||||||
const d = new Date(ms)
|
activity: PostcardActivity,
|
||||||
const pad = n => (n < 10 ? '0' + n : '' + n)
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
||||||
}
|
}
|
||||||
|
const cardType = computed(() => cardMap[props.notification.type] || PostcardBase)
|
||||||
|
|
||||||
|
// P2-2 fix:title 里的 #NNN 替换为 #${star_name}。
|
||||||
|
// 例如 "您被举报#87" → "您被举报#张艺兴";空 star_name 时保留原 title。
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
const starName = computed(() => data.value.star_name || '')
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
const raw = props.notification.title || ''
|
||||||
|
if (!starName.value) return raw
|
||||||
|
return raw.replace(/#\d+/g, '#' + starName.value)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.postcard {
|
|
||||||
background: #fff; border-radius: 16rpx; padding: 32rpx;
|
|
||||||
.postcard-header { border-bottom: 1rpx solid #f5f5f5; padding-bottom: 16rpx; margin-bottom: 16rpx; }
|
|
||||||
.postcard-title { font-size: 32rpx; font-weight: 600; display: block; margin-bottom: 12rpx; }
|
|
||||||
.postcard-meta {
|
|
||||||
display: flex; gap: 16rpx; font-size: 22rpx; color: #999;
|
|
||||||
.meta-type { color: #5C40FF; }
|
|
||||||
}
|
|
||||||
.postcard-content {
|
|
||||||
font-size: 28rpx; line-height: 1.7; color: #333; margin-bottom: 24rpx;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.postcard-extra {
|
|
||||||
.meta-row {
|
|
||||||
display: flex; font-size: 24rpx; color: #666;
|
|
||||||
padding: 8rpx 0;
|
|
||||||
.meta-key { color: #999; margin-right: 16rpx; min-width: 140rpx; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.postcard-unread-tip {
|
|
||||||
margin-top: 24rpx; padding-top: 16rpx; border-top: 1rpx solid #f5f5f5;
|
|
||||||
color: #f5222d; font-size: 24rpx;
|
|
||||||
}
|
|
||||||
.postcard-toolbar {
|
|
||||||
margin-top: 24rpx;
|
|
||||||
padding-top: 16rpx;
|
|
||||||
border-top: 1rpx solid #f5f5f5;
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
padding: 16rpx;
|
|
||||||
.toolbar-btn {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12rpx 0;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
&.toolbar-btn-read { background: #e8f4ff; color: #5C40FF; }
|
|
||||||
&.toolbar-btn-delete { background: #fff1f0; color: #f5222d; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
81
frontend/pages/mailbox/components/PostcardActivity.vue
Normal file
81
frontend/pages/mailbox/components/PostcardActivity.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<PostcardBase
|
||||||
|
:notification="notification"
|
||||||
|
:displayTitle="displayTitle"
|
||||||
|
@mark-read="emit('mark-read', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<view class="activity-body">
|
||||||
|
<view v-if="activityCover" class="activity-cover-wrap">
|
||||||
|
<image class="activity-cover" :src="activityCover" mode="aspectFill" />
|
||||||
|
</view>
|
||||||
|
<view v-if="activityTitle" class="activity-title-row">
|
||||||
|
<text class="activity-title">{{ activityTitle }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="activityId" class="chip-row chip-row-muted">
|
||||||
|
<text class="chip-label">活动 ID:</text>
|
||||||
|
<text class="chip-value">{{ activityId }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="activity-content">
|
||||||
|
<text>{{ notification.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PostcardBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PostcardBase from './PostcardBase.vue'
|
||||||
|
import { parseData } from './postcardHelpers.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notification: { type: Object, required: true },
|
||||||
|
displayTitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
const activityCover = computed(() => data.value.activity_cover || '')
|
||||||
|
const activityTitle = computed(() => data.value.activity_title || '')
|
||||||
|
const activityId = computed(() => data.value.activity_id || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.activity-body {
|
||||||
|
.activity-cover-wrap {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.activity-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.activity-title-row {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
.activity-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
.chip-label { color: #999; margin-right: 16rpx; min-width: 140rpx; }
|
||||||
|
.chip-value { color: #333; }
|
||||||
|
}
|
||||||
|
.chip-row-muted .chip-value { color: #888; font-family: monospace; }
|
||||||
|
.activity-content {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
frontend/pages/mailbox/components/PostcardBase.vue
Normal file
110
frontend/pages/mailbox/components/PostcardBase.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<view class="postcard-base">
|
||||||
|
<view class="postcard-header">
|
||||||
|
<text class="postcard-title">{{ displayTitle }}</text>
|
||||||
|
<view class="postcard-meta">
|
||||||
|
<text class="meta-type">[{{ typeLabelText }}]</text>
|
||||||
|
<text class="meta-time">{{ formatTime(notification.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 收件人/星 context:用户做主语,star 做 context,语义清晰 -->
|
||||||
|
<view v-if="userNickname || userMobile || starName" class="postcard-user">
|
||||||
|
<text v-if="userNickname" class="user-nick">{{ userNickname }}</text>
|
||||||
|
<text v-if="userMobile" class="user-mobile">({{ userMobile }})</text>
|
||||||
|
<text v-if="starName" class="user-star">@ {{ starName }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 类型专属 body 由父级通过 slot 注入 -->
|
||||||
|
<slot name="body" />
|
||||||
|
|
||||||
|
<view v-if="!notification.is_read" class="postcard-unread-tip">
|
||||||
|
<text>● 未读</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 取消 PostcardBase 自带 toolbar: 与 detail.vue 底部 toolbar 重复。
|
||||||
|
列表视图用 onLongPress 触发 ActionSheet(标已读/删除)代替。 -->
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { parseData, typeLabel, formatTime } from './postcardHelpers.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notification: { type: Object, required: true },
|
||||||
|
displayTitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
// emit 仍保留以防父组件需要
|
||||||
|
defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
const typeLabelText = computed(() => typeLabel(props.notification.type))
|
||||||
|
const starName = computed(() => data.value.star_name || '')
|
||||||
|
const userMobile = computed(() => data.value.user_mobile || '')
|
||||||
|
const userNickname = computed(() => data.value.user_nickname || '')
|
||||||
|
|
||||||
|
function onMarkRead() { emit('mark-read', props.notification) }
|
||||||
|
function onDelete() { emit('delete', props.notification) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.postcard-base {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
.postcard-header {
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.postcard-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.postcard-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
.meta-type { color: #5C40FF; }
|
||||||
|
.meta-star { color: #5C40FF; font-weight: 500; }
|
||||||
|
}
|
||||||
|
.postcard-user {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #888;
|
||||||
|
.user-nick { margin-left: 16rpx; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.postcard-unread-tip {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
color: #f5222d;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postcard-toolbar {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
.toolbar-btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
&.toolbar-btn-read { background: #e8f4ff; color: #5C40FF; }
|
||||||
|
&.toolbar-btn-delete { background: #fff1f0; color: #f5222d; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
frontend/pages/mailbox/components/PostcardFeedback.vue
Normal file
69
frontend/pages/mailbox/components/PostcardFeedback.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<PostcardBase
|
||||||
|
:notification="notification"
|
||||||
|
:displayTitle="displayTitle"
|
||||||
|
@mark-read="emit('mark-read', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<view class="feedback-body">
|
||||||
|
<view v-if="categoryCode" class="chip-row">
|
||||||
|
<text class="chip-label">分类:</text>
|
||||||
|
<text class="chip-value">{{ categoryCode }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="originalTitle" class="chip-row">
|
||||||
|
<text class="chip-label">原标题:</text>
|
||||||
|
<text class="chip-value">{{ originalTitle }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="feedbackId" class="chip-row chip-row-muted">
|
||||||
|
<text class="chip-label">反馈 ID:</text>
|
||||||
|
<text class="chip-value">{{ feedbackId }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="feedback-content">
|
||||||
|
<text>{{ notification.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PostcardBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PostcardBase from './PostcardBase.vue'
|
||||||
|
import { parseData } from './postcardHelpers.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notification: { type: Object, required: true },
|
||||||
|
displayTitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
const categoryCode = computed(() => data.value.category_code || '')
|
||||||
|
const originalTitle = computed(() => data.value.original_title || '')
|
||||||
|
const feedbackId = computed(() => data.value.feedback_id || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.feedback-body {
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
.chip-label { color: #999; margin-right: 16rpx; min-width: 140rpx; }
|
||||||
|
.chip-value { color: #333; }
|
||||||
|
}
|
||||||
|
.chip-row-muted .chip-value { color: #888; font-family: monospace; }
|
||||||
|
.feedback-content {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
background: #f7f8ff;
|
||||||
|
border-left: 4rpx solid #5C40FF;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
frontend/pages/mailbox/components/PostcardReport.vue
Normal file
88
frontend/pages/mailbox/components/PostcardReport.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<PostcardBase
|
||||||
|
:notification="notification"
|
||||||
|
:displayTitle="displayTitle"
|
||||||
|
@mark-read="emit('mark-read', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<view class="report-body">
|
||||||
|
<view class="badge-row">
|
||||||
|
<text class="badge" :class="actionColor">{{ actionText }}</text>
|
||||||
|
<text v-if="targetType" class="target">目标: {{ targetType }}#{{ targetId }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="categoryCode" class="chip-row">
|
||||||
|
<text class="chip-label">分类:</text>
|
||||||
|
<text class="chip-value">{{ categoryCode }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="reportId" class="chip-row chip-row-muted">
|
||||||
|
<text class="chip-label">举报 ID:</text>
|
||||||
|
<text class="chip-value">{{ reportId }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="report-content">
|
||||||
|
<text>{{ notification.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PostcardBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PostcardBase from './PostcardBase.vue'
|
||||||
|
import { parseData, actionLabel, actionColorClass } from './postcardHelpers.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notification: { type: Object, required: true },
|
||||||
|
displayTitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
// 后端 moderation_admin.py 用 action(规范名);老数据可能用 resolved_action
|
||||||
|
const rawAction = computed(() => data.value.resolved_action || data.value.action || '')
|
||||||
|
const actionText = computed(() => actionLabel(rawAction.value))
|
||||||
|
const actionColor = computed(() => actionColorClass(rawAction.value))
|
||||||
|
const targetType = computed(() => data.value.target_type || '')
|
||||||
|
const targetId = computed(() => data.value.target_id || '')
|
||||||
|
const categoryCode = computed(() => data.value.category_code || '')
|
||||||
|
const reportId = computed(() => data.value.report_id || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.report-body {
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
&.badge-red { background: #f5222d; }
|
||||||
|
&.badge-orange { background: #fa8c16; }
|
||||||
|
&.badge-green { background: #52c41a; }
|
||||||
|
&.badge-gray { background: #999; }
|
||||||
|
}
|
||||||
|
.target { font-size: 24rpx; color: #666; }
|
||||||
|
}
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
.chip-label { color: #999; margin-right: 16rpx; min-width: 140rpx; }
|
||||||
|
.chip-value { color: #333; }
|
||||||
|
}
|
||||||
|
.chip-row-muted .chip-value { color: #888; font-family: monospace; }
|
||||||
|
.report-content {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
frontend/pages/mailbox/components/PostcardTarget.vue
Normal file
88
frontend/pages/mailbox/components/PostcardTarget.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<PostcardBase
|
||||||
|
:notification="notification"
|
||||||
|
:displayTitle="displayTitle"
|
||||||
|
@mark-read="emit('mark-read', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<view class="target-body">
|
||||||
|
<view class="badge-row">
|
||||||
|
<text class="badge" :class="actionColor">{{ actionText }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="reasonSummary" class="reason-summary">
|
||||||
|
<text class="reason-label">原因:</text>
|
||||||
|
<text class="reason-text">{{ reasonSummary }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="targetType" class="chip-row">
|
||||||
|
<text class="chip-label">涉及对象:</text>
|
||||||
|
<text class="chip-value">{{ targetType }}#{{ targetId }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="target-content">
|
||||||
|
<text>{{ notification.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PostcardBase>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PostcardBase from './PostcardBase.vue'
|
||||||
|
import { parseData, actionLabel, actionColorClass } from './postcardHelpers.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
notification: { type: Object, required: true },
|
||||||
|
displayTitle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['mark-read', 'delete'])
|
||||||
|
|
||||||
|
const data = computed(() => parseData(props.notification.data))
|
||||||
|
const rawAction = computed(() => data.value.action || '')
|
||||||
|
const actionText = computed(() => actionLabel(rawAction.value))
|
||||||
|
const actionColor = computed(() => actionColorClass(rawAction.value))
|
||||||
|
const reasonSummary = computed(() => data.value.reason_summary || '')
|
||||||
|
const targetType = computed(() => data.value.target_type || '')
|
||||||
|
const targetId = computed(() => data.value.target_id || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.target-body {
|
||||||
|
.badge-row { margin-bottom: 12rpx; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
&.badge-red { background: #f5222d; }
|
||||||
|
&.badge-orange { background: #fa8c16; }
|
||||||
|
&.badge-green { background: #52c41a; }
|
||||||
|
&.badge-gray { background: #999; }
|
||||||
|
}
|
||||||
|
.reason-summary {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-left: 4rpx solid #fa8c16;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
.reason-label { color: #fa8c16; margin-right: 8rpx; font-weight: 500; }
|
||||||
|
.reason-text { color: #333; }
|
||||||
|
}
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
|
.chip-label { color: #999; margin-right: 16rpx; min-width: 140rpx; }
|
||||||
|
.chip-value { color: #333; }
|
||||||
|
}
|
||||||
|
.target-content {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
frontend/pages/mailbox/components/postcardHelpers.js
Normal file
62
frontend/pages/mailbox/components/postcardHelpers.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// 共享 helper:被 PostcardBase / PostcardFeedback / PostcardReport / PostcardTarget /
|
||||||
|
// PostcardActivity / Postcard dispatcher 共用。
|
||||||
|
// 抽出来避免每个 vue 组件重复 parseData / typeMap / actionLabel / actionColor / formatTime。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 notification.data (后端 proto structpb.AsMap() 已经是 object;但老数据/测试数据可能是 JSON string)。
|
||||||
|
* 任何异常/非对象 → {}。
|
||||||
|
*/
|
||||||
|
export function parseData(raw) {
|
||||||
|
if (!raw) return {}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return JSON.parse(raw) } catch (e) { return {} }
|
||||||
|
}
|
||||||
|
if (typeof raw === 'object') return raw
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型 → 中文展示名 (与 useMailboxCenter.js#GROUPS.label 对齐)
|
||||||
|
export const typeMap = {
|
||||||
|
feedback_replied: '反馈回复',
|
||||||
|
report_resolved: '举报结果',
|
||||||
|
target_reported: '被举报',
|
||||||
|
activity: '活动通知',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeLabel(type) {
|
||||||
|
return typeMap[type] || type || '通知'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理动作 → 中文展示
|
||||||
|
export const actionLabelMap = {
|
||||||
|
takedown: '已下架',
|
||||||
|
ban: '已封禁',
|
||||||
|
warn: '已警告',
|
||||||
|
dismiss: '已驳回',
|
||||||
|
restore: '已解除',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionLabel(a) {
|
||||||
|
return actionLabelMap[a] || a || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理动作 → 角标 CSS class
|
||||||
|
// red=takedown/ban(严重), orange=warn(警告), green=restore(恢复), gray=dismiss(驳回)/未知
|
||||||
|
export function actionColorClass(a) {
|
||||||
|
switch (a) {
|
||||||
|
case 'takedown':
|
||||||
|
case 'ban': return 'badge-red'
|
||||||
|
case 'warn': return 'badge-orange'
|
||||||
|
case 'restore': return 'badge-green'
|
||||||
|
case 'dismiss': return 'badge-gray'
|
||||||
|
default: return 'badge-gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间戳(秒/毫秒都兼容) → YYYY-MM-DD HH:MM
|
||||||
|
export function formatTime(ms) {
|
||||||
|
if (!ms) return ''
|
||||||
|
const d = new Date(typeof ms === 'number' && ms < 1e12 ? ms * 1000 : ms)
|
||||||
|
const pad = n => (n < 10 ? '0' + n : '' + n)
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
@ -30,15 +30,17 @@ export function useMailboxCenter() {
|
|||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
store.commit('mailbox/SET_LOADING', true)
|
store.commit('mailbox/SET_LOADING', true)
|
||||||
try {
|
try {
|
||||||
await Promise.all(GROUPS.map(g =>
|
await Promise.all(GROUPS.map(g => {
|
||||||
getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
|
return getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
const items = resp.data?.items || []
|
const items = resp.data?.items || []
|
||||||
store.commit('mailbox/SET_ITEMS', { type: g.type, items, sortUnreadFirst: true })
|
store.commit('mailbox/SET_ITEMS', { type: g.type, items, sortUnreadFirst: true })
|
||||||
store.commit('mailbox/SET_NO_MORE', { type: g.type, value: items.length < 20 })
|
store.commit('mailbox/SET_NO_MORE', { type: g.type, value: items.length < 20 })
|
||||||
if (resp.data?.unreadByType) store.commit('mailbox/REPLACE_UNREAD_BY_TYPE', resp.data.unreadByType)
|
if (resp.data?.unreadByType) store.commit('mailbox/REPLACE_UNREAD_BY_TYPE', resp.data.unreadByType)
|
||||||
})
|
})
|
||||||
))
|
.catch((e) => {
|
||||||
|
})
|
||||||
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
store.commit('mailbox/SET_LOADING', false)
|
store.commit('mailbox/SET_LOADING', false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
|
import { onLoad, onShow, onHide, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
// import Header from '../components/Header.vue'
|
// import Header from '../components/Header.vue'
|
||||||
import MailboxGroup from './components/MailboxGroup.vue'
|
import MailboxGroup from './components/MailboxGroup.vue'
|
||||||
@ -83,6 +83,16 @@ onShow(() => {
|
|||||||
onHide(() => {
|
onHide(() => {
|
||||||
store.commit('mailbox/SET_IS_IN_PAGE', false)
|
store.commit('mailbox/SET_IS_IN_PAGE', false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 下拉刷新:补 trigger loadAll + 收尾 stopPullDownRefresh
|
||||||
|
// (pages.json 配了 enablePullDownRefresh: true 才有效)
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
try {
|
||||||
|
await loadAll()
|
||||||
|
} finally {
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@ -1186,9 +1186,17 @@ export function unregisterDeviceApi(cid = '') {
|
|||||||
// gateway controller 用 g.Query("type") 读 type,data:{} 不会被解。
|
// gateway controller 用 g.Query("type") 读 type,data:{} 不会被解。
|
||||||
export function getNotificationsApi(params = {}) {
|
export function getNotificationsApi(params = {}) {
|
||||||
const { type, tab = '', page = 1, pageSize = 20 } = params || {}
|
const { type, tab = '', page = 1, pageSize = 20 } = params || {}
|
||||||
const qs = new URLSearchParams({ type, page: String(page), page_size: String(pageSize) })
|
if (!type) {
|
||||||
if (tab) qs.append('tab', tab)
|
// 必须在 useMailboxCenter loadAll 调用前 assert (无 catch 也显眼)
|
||||||
return request({ url: `/api/v1/notifications?${qs}`, method: 'GET' })
|
throw new Error('[getNotificationsApi] type is required')
|
||||||
|
}
|
||||||
|
// ⚠️ V1.2.5 修正:不用 URLSearchParams (uni-app dev runtime 在 template literal
|
||||||
|
// 隐式 toString 抛错,见 .superpowers/sdd/inbox-runtime-fix-report.md)
|
||||||
|
// 用模板字符串 + encodeURIComponent 直接拼。
|
||||||
|
let url = `/api/v1/notifications?type=${encodeURIComponent(type)}` +
|
||||||
|
`&page=${page}&page_size=${pageSize}`
|
||||||
|
if (tab) url += `&tab=${encodeURIComponent(tab)}`
|
||||||
|
return request({ url, method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUnreadCountApi —— 拉全局未读计数。
|
// getUnreadCountApi —— 拉全局未读计数。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user