feat:修改详细页面,修改为每个类型的页面不对
This commit is contained in:
parent
18aff3b682
commit
99775f1d11
@ -2,21 +2,36 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dubbo.apache.org/dubbo-go/v3/client"
|
||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
pbNotif "github.com/topfans/backend/pkg/proto/notification"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"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 通知相关控制器
|
||||
type NotificationController struct {
|
||||
notifService pbNotif.NotificationService
|
||||
@ -214,9 +229,10 @@ func (ctrl *NotificationController) GetNotifications(g *gin.Context) {
|
||||
}
|
||||
|
||||
// 转换为 map 列表(Notification 含 structpb.Struct 序列化友好)
|
||||
db := database.GetDB()
|
||||
items := make([]map[string]interface{}, 0, len(resp.Items))
|
||||
for _, n := range resp.Items {
|
||||
items = append(items, convertNotification(n))
|
||||
items = append(items, convertNotification(db, n))
|
||||
}
|
||||
|
||||
response.Success(g, gin.H{
|
||||
@ -584,7 +600,9 @@ func (ctrl *NotificationController) DeleteByTarget(g *gin.Context) {
|
||||
|
||||
// convertNotification 将 *pbNotif.Notification 转为前端友好的 map
|
||||
// (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 {
|
||||
return nil
|
||||
}
|
||||
@ -604,10 +622,12 @@ func convertNotification(n *pbNotif.Notification) map[string]interface{} {
|
||||
"total_count": n.TotalCount,
|
||||
}
|
||||
|
||||
// data 字段: *structpb.Struct → map
|
||||
// data 字段: *structpb.Struct → map;旧端 key 全部保留,只追加新 key。
|
||||
data := map[string]interface{}{}
|
||||
if n.Data != nil {
|
||||
item["data"] = n.Data.AsMap()
|
||||
data = n.Data.AsMap()
|
||||
}
|
||||
item["data"] = data
|
||||
|
||||
// actors 字段
|
||||
if len(n.Actors) > 0 {
|
||||
@ -626,9 +646,78 @@ func convertNotification(n *pbNotif.Notification) map[string]interface{} {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 入口(无鉴权,内网部署) ==========
|
||||
//
|
||||
// AdminCreateNotification 供 Python admin (8081) 调用的批量发送入口。
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"appid" : "__UNI__F199FF4",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.5",
|
||||
"versionCode" : 118,
|
||||
"versionCode" : 119,
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
@ -106,7 +106,7 @@
|
||||
// "share" : {
|
||||
// "weixin" : {
|
||||
// "appid" : "<REPLACE_WITH_WEIXIN_APPID>",
|
||||
// "UniversalLinks" : "https://topfans.online/share/"
|
||||
// "UniversalLinks" : "https://topfans.online/uni-universallinks/"
|
||||
// },
|
||||
// "qq" : {
|
||||
// "appid" : "<REPLACE_WITH_QQ_APPID>"
|
||||
|
||||
@ -397,7 +397,8 @@
|
||||
"navigationStyle": "custom",
|
||||
"app-plus": {
|
||||
"bounce": "none"
|
||||
}
|
||||
},
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,124 +1,46 @@
|
||||
<template>
|
||||
<view class="postcard">
|
||||
<view class="postcard-header">
|
||||
<text class="postcard-title">{{ notification.title }}</text>
|
||||
<view class="postcard-meta">
|
||||
<text v-if="notification.type" class="meta-type">[{{ typeLabel }}]</text>
|
||||
<text class="meta-time">{{ formatTime(notification.created_at) }}</text>
|
||||
</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>
|
||||
<component
|
||||
:is="cardType"
|
||||
:notification="notification"
|
||||
:displayTitle="displayTitle"
|
||||
@mark-read="emit('mark-read', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({ notification: { type: Object, required: true } })
|
||||
defineEmits(['mark-read', 'delete'])
|
||||
import PostcardBase from './PostcardBase.vue'
|
||||
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 = {
|
||||
feedback_replied: '反馈回复',
|
||||
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 props = defineProps({
|
||||
notification: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['mark-read', 'delete'])
|
||||
|
||||
function actionLabel(a) {
|
||||
return { takedown: '已下架', ban: '已封禁', warn: '已警告', dismiss: '已驳回', restore: '已解除' }[a] || a
|
||||
}
|
||||
function formatTime(ms) {
|
||||
if (!ms) return ''
|
||||
const d = new Date(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())}`
|
||||
// 按 notification.type 选择类型专属卡片;未知类型回退到 PostcardBase(只显示 chrome)。
|
||||
const cardMap = {
|
||||
feedback_replied: PostcardFeedback,
|
||||
report_resolved: PostcardReport,
|
||||
target_reported: PostcardTarget,
|
||||
activity: PostcardActivity,
|
||||
}
|
||||
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>
|
||||
|
||||
<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>
|
||||
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() {
|
||||
store.commit('mailbox/SET_LOADING', true)
|
||||
try {
|
||||
await Promise.all(GROUPS.map(g =>
|
||||
getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
|
||||
await Promise.all(GROUPS.map(g => {
|
||||
return getNotificationsApi({ type: g.type, page: 1, page_size: 20 })
|
||||
.then((resp) => {
|
||||
const items = resp.data?.items || []
|
||||
store.commit('mailbox/SET_ITEMS', { type: g.type, items, sortUnreadFirst: true })
|
||||
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)
|
||||
})
|
||||
))
|
||||
.catch((e) => {
|
||||
})
|
||||
}))
|
||||
} finally {
|
||||
store.commit('mailbox/SET_LOADING', false)
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShow, onHide, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useStore } from 'vuex'
|
||||
// import Header from '../components/Header.vue'
|
||||
import MailboxGroup from './components/MailboxGroup.vue'
|
||||
@ -83,6 +83,16 @@ onShow(() => {
|
||||
onHide(() => {
|
||||
store.commit('mailbox/SET_IS_IN_PAGE', false)
|
||||
})
|
||||
|
||||
// 下拉刷新:补 trigger loadAll + 收尾 stopPullDownRefresh
|
||||
// (pages.json 配了 enablePullDownRefresh: true 才有效)
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadAll()
|
||||
} finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -1186,9 +1186,17 @@ export function unregisterDeviceApi(cid = '') {
|
||||
// gateway controller 用 g.Query("type") 读 type,data:{} 不会被解。
|
||||
export function getNotificationsApi(params = {}) {
|
||||
const { type, tab = '', page = 1, pageSize = 20 } = params || {}
|
||||
const qs = new URLSearchParams({ type, page: String(page), page_size: String(pageSize) })
|
||||
if (tab) qs.append('tab', tab)
|
||||
return request({ url: `/api/v1/notifications?${qs}`, method: 'GET' })
|
||||
if (!type) {
|
||||
// 必须在 useMailboxCenter loadAll 调用前 assert (无 catch 也显眼)
|
||||
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 —— 拉全局未读计数。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user