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]);
+}