feat(notify):添加通知显示

This commit is contained in:
qian committed 2026-09-04 01:03:53 +08:00
1 parent 637948eea0
commit a247b3a53b
7 files changed
+366 -1

No files matched your search

+1
View File
@@ -258,6 +258,7 @@ type IAdminV1 interface {
AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error)
AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error)
AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error)
AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error)
TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error)
TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error)
TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error)
+24
View File
@@ -0,0 +1,24 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// === 顶栏铃铛(客服支持待办聚合) ===
// AdminSupportPendingSummaryReq 顶栏铃铛的待办计数。
// 口径与工作台刻意不同:工作台只报「超 SLA / 未分配」等异常项避免永久红点;
// 铃铛报全量待处理 —— 管理员处理一条少一条,归零即「没有客户在等回复」,
// 是运营收件箱语义而非消息通知语义(无已读概念,不落消息表)。
type AdminSupportPendingSummaryReq struct {
g.Meta `path:"/support/pending-summary" method:"get" mime:"json" tags:"管理后台-待办提醒" summary:"顶栏铃铛待办计数"`
}
type AdminSupportPendingSummaryRes struct {
Tickets int `json:"tickets" dc:"待处理工单数(pending + reopened)"`
TicketsOverdue int `json:"tickets_overdue" dc:"其中超 SLA 未首次响应的工单数"`
Feedbacks int `json:"feedbacks" dc:"待处理反馈数"`
FeedbacksWait string `json:"feedbacks_wait" dc:"最早一条待处理反馈已等待时长(如「3 天」;无待处理时为空)"`
Total int `json:"total" dc:"tickets + feedbacks,铃铛角标总数"`
GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"`
}
@@ -0,0 +1,12 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) {
return service.Admin().GetSupportPendingSummary(ctx, req)
}
+103
View File
@@ -0,0 +1,103 @@
package admin
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
)
// ============================================================
// 顶栏铃铛:客服支持待办聚合
//
// 与工作台(workbench.go)的分工:工作台回答「哪些事异常了」,
// 刻意不报工单总数避免永久红点;铃铛回答「有多少客户在等回复」,
// 报全量 pending —— 处理完自动归零,不需要任何人点「完成」。
// 待办不落库、实时派生,整份结果进 Redis 缓存 30s(与工作台同模式),
// 菜单红点轮询与铃铛各自独立缓存键,互不挤兑。
// ============================================================
const (
supportPendingCacheKey = "admin:support_pending:v1"
supportPendingCacheTTL = 30 * time.Second
)
// supportPendingCollected 一轮聚合的完整结果(进缓存的就是它)。
type supportPendingCollected struct {
Tickets int `json:"tickets"`
TicketsOverdue int `json:"tickets_overdue"`
Feedbacks int `json:"feedbacks"`
FeedbacksWait string `json:"feedbacks_wait"`
GeneratedAt string `json:"generated_at"`
}
// GetSupportPendingSummary 顶栏铃铛待办计数。
func (s *sAdmin) GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) {
col := s.collectSupportPendingCached(ctx)
return &v1.AdminSupportPendingSummaryRes{
Tickets: col.Tickets,
TicketsOverdue: col.TicketsOverdue,
Feedbacks: col.Feedbacks,
FeedbacksWait: col.FeedbacksWait,
Total: col.Tickets + col.Feedbacks,
GeneratedAt: col.GeneratedAt,
}, nil
}
// collectSupportPendingCached 读缓存,未命中则重新聚合。
// 缓存读写失败一律降级为直接查询 —— 铃铛不能因为 Redis 抖动就消失。
func (s *sAdmin) collectSupportPendingCached(ctx context.Context) *supportPendingCollected {
if v, err := g.Redis().Get(ctx, supportPendingCacheKey); err == nil && !v.IsEmpty() {
var col supportPendingCollected
if json.Unmarshal(v.Bytes(), &col) == nil {
return &col
}
}
col := s.collectSupportPending(ctx)
if b, err := json.Marshal(col); err == nil {
if _, err := g.Redis().Do(ctx, "SET", supportPendingCacheKey, b, "EX", int(supportPendingCacheTTL.Seconds())); err != nil {
g.Log().Warningf(ctx, "support pending: 写缓存失败: %v", err)
}
}
return col
}
// collectSupportPending 两个独立查询,任一失败只丢自己那部分(记 warning),
// 不拖垮整个铃铛 —— 半个铃铛也好过没有铃铛。
func (s *sAdmin) collectSupportPending(ctx context.Context) *supportPendingCollected {
col := &supportPendingCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)}
// 工单:pending + reopened 全量。超 SLA 口径与工作台 collector 一致:
// 仅 pending 计超时(reopened 说明已有人处理过,不算「未首响」)。
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt,
COUNT(*) FILTER (WHERE status = 'pending' AND created_at <= ?) AS overdue
FROM spt_tickets
WHERE status IN ('pending', 'reopened')`, gtime.Now().Add(-wbTicketSLAHours*time.Hour))
if err != nil {
g.Log().Warningf(ctx, "support pending: 查询待处理工单失败: %v", err)
} else if len(rows) > 0 {
col.Tickets = rows[0]["cnt"].Int()
col.TicketsOverdue = rows[0]["overdue"].Int()
}
// 反馈:pending 全量 + 最早一条的等待时长(反馈无 SLA 概念,只展示积压程度)。
fbRows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt, MIN(created_at) AS oldest
FROM spt_feedbacks WHERE status = 'pending'`)
if err != nil {
g.Log().Warningf(ctx, "support pending: 查询待处理反馈失败: %v", err)
} else if len(fbRows) > 0 {
col.Feedbacks = fbRows[0]["cnt"].Int()
if oldest := fbRows[0]["oldest"].GTime(); oldest != nil {
col.FeedbacksWait = humanizeSince(oldest)
}
}
return col
}
+2
View File
@@ -152,6 +152,8 @@ var adminPermissionRules = []permissionRule{
{method: "PUT", prefix: "/api/admin/tickets/", suffix: "/assign", perm: "support:edit"},
{method: "POST", prefix: "/api/admin/tickets/", suffix: "/reply", perm: "support:reply"},
{method: "PUT", prefix: "/api/admin/tickets/", suffix: "/status", perm: "support:edit"},
// 顶栏铃铛待办计数(工单 + 反馈聚合,权限收敛在 support:view)
{method: "GET", path: "/api/admin/support/pending-summary", perm: "support:view"},
// ── help center 帮助中心 ──
{method: "GET", path: "/api/admin/help-categories", perm: "support:view"},
+2
View File
@@ -527,6 +527,8 @@ type (
// 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试
// 三次也不会变好),不该让管理员多等十几秒的退避。
TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error)
// GetSupportPendingSummary 顶栏铃铛待办计数。
GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error)
// TaskList 大模型异步任务列表
TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error)
// TaskDetail 大模型异步任务详情
+222 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, provide } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, provide, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import axios from 'axios'
@@ -483,8 +483,69 @@ function handleClickOutside(e: MouseEvent) {
if (!target.closest('.admin-header__user-wrapper')) {
userMenuVisible.value = false
}
if (!target.closest('.admin-header__bell-wrapper')) {
bellVisible.value = false
}
}
// --- 顶栏铃铛(客服支持待办) ---
// 与工作台红点分工:工作台只报异常项(超 SLA / 未分配),铃铛报全量待处理
// (pending + reopened 工单、pending 反馈)—— 有客户在等回复就挂着数字,
// 处理一条少一条,归零即安静。仅对有 support:view 权限的管理员显示和轮询。
const canSeeBell = computed(() =>
isSuperAdminUser.value || permissionSet.value.has('support:view'),
)
const bellVisible = ref(false)
const bellData = ref<{
tickets: number
tickets_overdue: number
feedbacks: number
feedbacks_wait: string
total: number
} | null>(null)
let bellTimer: ReturnType<typeof setInterval> | null = null
async function fetchBellSummary() {
try {
const res: any = await request.get('/admin/support/pending-summary', {
_suppressErrorMsg: true,
} as any)
bellData.value = res.data?.data || null
} catch {
// 静默:铃铛拉取失败不打扰用户,保留上一次数值
}
}
function startBellPolling() {
fetchBellSummary()
if (!bellTimer) {
bellTimer = setInterval(fetchBellSummary, 60000)
}
}
function stopBellPolling() {
if (bellTimer) {
clearInterval(bellTimer)
bellTimer = null
}
}
function toggleBell() {
bellVisible.value = !bellVisible.value
// 打开面板时立即刷新,避免展示 60s 轮询间隙里的旧数字
if (bellVisible.value) fetchBellSummary()
}
function goFromBell(name: string) {
bellVisible.value = false
router.push({ name })
}
// 权限可能晚于挂载就绪(fetchMe 异步),用 watch 而不是 onMounted 一次性判断
watch(canSeeBell, (visible) => {
if (visible) startBellPolling()
}, { immediate: true })
// --- 工作台菜单红点 ---
// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
@@ -544,6 +605,7 @@ onUnmounted(() => {
window.removeEventListener('resize', updateMobile)
clearTimeout(popupHideTimer!)
if (badgeTimer) clearInterval(badgeTimer)
stopBellPolling()
stopPolling()
unmountWatermark()
})
@@ -696,6 +758,50 @@ onUnmounted(() => {
</div>
<div class="admin-header__right">
<!-- 待办铃铛:客服支持待办(工单 + 反馈),有 support:view 权限才显示 -->
<div v-if="canSeeBell" class="admin-header__bell-wrapper" style="position: relative;">
<button
type="button"
class="admin-header__bell-btn"
:class="{ 'admin-header__bell-btn--active': bellVisible }"
@click.stop="toggleBell"
title="待办提醒"
>
<IconNotification class="admin-header__bell-icon" />
<span
v-if="bellData && bellData.total > 0"
class="admin-header__bell-badge"
:class="{ 'admin-header__bell-badge--urgent': bellData.tickets_overdue > 0 }"
>{{ bellData.total > 99 ? '99+' : bellData.total }}</span>
</button>
<Transition name="fade">
<div v-if="bellVisible" class="admin-header__bell-panel">
<div class="admin-header__bell-panel-head">待办提醒</div>
<template v-if="bellData && bellData.total > 0">
<div class="admin-header__bell-item" @click="goFromBell('AdminTickets')">
<div class="admin-header__bell-item-main">
<span>工单待处理</span>
<span class="admin-header__bell-item-count">{{ bellData.tickets }}</span>
</div>
<div v-if="bellData.tickets_overdue > 0" class="admin-header__bell-item-sub admin-header__bell-item-sub--danger">
{{ bellData.tickets_overdue }} 条已超 SLA 未响应
</div>
</div>
<div class="admin-header__bell-item" @click="goFromBell('AdminFeedback')">
<div class="admin-header__bell-item-main">
<span>反馈待处理</span>
<span class="admin-header__bell-item-count">{{ bellData.feedbacks }}</span>
</div>
<div v-if="bellData.feedbacks_wait" class="admin-header__bell-item-sub">
最早 1 条已等待 {{ bellData.feedbacks_wait }}
</div>
</div>
</template>
<div v-else class="admin-header__bell-empty">暂无待办,一切安静</div>
</div>
</Transition>
</div>
<!-- 帮助文档:新窗口打开文档站 -->
<a
class="admin-header__docs-btn"
@@ -1161,6 +1267,117 @@ onUnmounted(() => {
flex-shrink: 0;
}
/* ===== 待办铃铛 ===== */
.admin-header__bell-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-right: 12px;
border: none;
border-radius: 8px;
color: var(--ta-text-secondary);
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
background: none;
}
.admin-header__bell-btn:hover,
.admin-header__bell-btn--active {
background: var(--ta-bg-secondary);
color: var(--ta-text-primary);
}
.admin-header__bell-icon {
font-size: 17px;
}
.admin-header__bell-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 16px;
text-align: center;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
background: var(--color-fill-3);
color: var(--color-text-2);
}
/* 红色只留给「现在就得看」:有超 SLA 工单才转红,与工作台角标策略一致 */
.admin-header__bell-badge--urgent {
background: #f53f3f;
color: #fff;
}
.admin-header__bell-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 280px;
background: var(--ta-bg-card);
border: 1px solid var(--ta-border-light);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 4px;
z-index: 100;
}
.admin-header__bell-panel-head {
padding: 8px 12px 6px;
font-size: 12px;
font-weight: 600;
color: var(--ta-text-tertiary);
}
.admin-header__bell-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all var(--ta-duration-fast) var(--ta-ease);
}
.admin-header__bell-item:hover {
background: var(--ta-bg-secondary);
}
.admin-header__bell-item-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--ta-text-secondary);
}
.admin-header__bell-item-count {
font-size: 15px;
font-weight: 600;
color: var(--ta-text-primary);
}
.admin-header__bell-item-sub {
margin-top: 2px;
font-size: 12px;
color: var(--ta-text-tertiary);
}
.admin-header__bell-item-sub--danger {
color: #f53f3f;
}
.admin-header__bell-empty {
padding: 20px 0;
text-align: center;
font-size: 13px;
color: var(--ta-text-tertiary);
}
/* ===== 帮助文档胶囊入口 ===== */
.admin-header__docs-btn {
display: inline-flex;
@@ -1192,6 +1409,10 @@ onUnmounted(() => {
/* 窄屏收起文字,退化为纯图标小胶囊(与右侧用户名在移动端隐藏的处理一致) */
@media (max-width: 480px) {
.admin-header__bell-btn {
margin-right: 8px;
}
.admin-header__docs-btn {
width: 34px;
padding: 0;