feat(notify):添加通知显示
This commit is contained in:
1 parent
637948eea0
commit
a247b3a53b
7 files changed
+366
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 大模型异步任务详情
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user