feat:修改详细页面,修改为每个类型的页面不对

This commit is contained in:
zerosaturation 2026-07-15 19:16:28 +08:00
parent 18aff3b682
commit 99775f1d11
13 changed files with 668 additions and 138 deletions

View File

@ -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,7 +600,9 @@ 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
} }
@ -604,10 +622,12 @@ func convertNotification(n *pbNotif.Notification) map[string]interface{} {
"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) 调用的批量发送入口。

View File

@ -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>"

View File

@ -397,7 +397,8 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"app-plus": { "app-plus": {
"bounce": "none" "bounce": "none"
} },
"enablePullDownRefresh": true
} }
}, },
{ {

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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())}`
}

View File

@ -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)
} }

View File

@ -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>

View File

@ -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 —— 拉全局未读计数。