diff --git a/next/api/src/controller/ticket.ts b/next/api/src/controller/ticket.ts index 803cedc2b..8926dd79f 100644 --- a/next/api/src/controller/ticket.ts +++ b/next/api/src/controller/ticket.ts @@ -6,19 +6,23 @@ import { Body, Controller, Ctx, + CurrentUser, Delete, Get, Param, Post, + Query, ResponseBody, UseMiddlewares, } from '@/common/http'; -import { ZodValidationPipe } from '@/common/pipe'; +import { ParseBoolPipe, ZodValidationPipe } from '@/common/pipe'; import { customerServiceOnly, staffOnly } from '@/middleware'; import { UpdateData } from '@/orm'; import router from '@/router/ticket'; import { Ticket } from '@/model/Ticket'; import { TicketListItemResponse } from '@/response/ticket'; +import { User } from '@/model/User'; +import { redis } from '@/cache'; const createAssociatedTicketSchema = z.object({ ticketId: z.string(), @@ -106,4 +110,27 @@ export class TicketController { await Ticket.updateSome(updatePairs, { useMasterKey: true }); } + + // The :id is not used to avoid fetch ticket data by router.param + // This API may be called frequently, and we do not care if the ticket exists + @Get(':roomId/viewers') + @UseMiddlewares(staffOnly) + async getTicketViewers( + @Param('roomId') id: string, + @Query('excludeSelf', ParseBoolPipe) excludeSelf: boolean, + @CurrentUser() user: User + ) { + const key = `ticket_viewers:${id}`; + const now = Date.now(); + const results = await redis + .pipeline() + .zadd(key, now, user.id) // add current user to viewer set + .expire(key, 100) // set ttl to 100 seconds + .zremrangebyrank(key, 100, -1) // keep viewer set small + .zremrangebyscore(key, '-inf', now - 1000 * 60) // remove viewers active 60 seconds ago + .zrevrange(key, 0, -1) // get all viewers + .exec(); + const viewers: string[] = _.last(results)?.[1] || []; + return excludeSelf ? viewers.filter((id) => id !== user.id) : viewers; + } } diff --git a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx index 76c2cc2da..7cebd3180 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx @@ -57,6 +57,7 @@ import { CustomFields } from './components/CustomFields'; import { useTicketOpsLogs, useTicketReplies } from './timeline-data'; import { RecentTickets } from './components/RecentTickets'; import { Evaluation } from './components/Evaluation'; +import { TicketViewers } from './components/TicketViewers'; export function TicketDetail() { const { id } = useParams() as { id: string }; @@ -191,6 +192,7 @@ export function TicketDetail() { interface TicketInfoProps { ticket: { + id: string; nid: number; title: string; status: number; @@ -225,6 +227,7 @@ function TicketInfo({ } onBack={onBack} extra={[ + , + {viewers?.map((viewer) => ( + + + {viewer.nickname.slice(0, 1)} + + + ))} + + ); +} diff --git a/next/web/src/App/Admin/Tickets/Ticket/hooks/useTicketViewers.ts b/next/web/src/App/Admin/Tickets/Ticket/hooks/useTicketViewers.ts new file mode 100644 index 000000000..cf4b08d59 --- /dev/null +++ b/next/web/src/App/Admin/Tickets/Ticket/hooks/useTicketViewers.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { useQuery } from 'react-query'; +import moment from 'moment'; +import _ from 'lodash'; + +import { http } from '@/leancloud'; +import { useUsers } from '@/api/user'; + +export interface UseTicketViewersOptions { + id: string; + createdAt: string; +} + +export function useTicketViewers(ticket: UseTicketViewersOptions) { + const refetchInterval = useMemo(() => { + const hours = moment().diff(ticket.createdAt, 'hour'); + if (hours < 1) { + // 1 小时内创建的工单每 10 秒刷新一次 + return 10000; + } + // 增加刷新时间, 上限为 24 小时 / 40 秒 + return Math.floor(30000 * (Math.min(24, hours) / 24)) + 10000; + }, [ticket.createdAt]); + + const { data: viewerIds } = useQuery({ + queryKey: ['TicketViewers', ticket.id], + queryFn: async () => { + const res = await http.get(`/api/2/tickets/${ticket.id}/viewers`, { + params: { excludeSelf: 1 }, + }); + return res.data; + }, + cacheTime: 0, + refetchInterval, + keepPreviousData: true, + }); + + const sortedIds = useMemo(() => (viewerIds || []).slice().sort(), [viewerIds]); + + const { data: viewers } = useUsers({ + id: sortedIds, + queryOptions: { + enabled: sortedIds.length > 0, + keepPreviousData: true, + }, + }); + + return useMemo(() => { + if (viewerIds && viewers) { + const viewerMap = _.keyBy(viewers, (v) => v.id); + return viewerIds.map((id) => viewerMap[id]).filter(Boolean); + } + return []; + }, [viewerIds, viewers]); +}