Skip to content

Commit

Permalink
feat(next/web): display ticket viewers (#949)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdjdd authored Nov 23, 2023
1 parent d251523 commit e045295
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 1 deletion.
29 changes: 28 additions & 1 deletion next/api/src/controller/ticket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -191,6 +192,7 @@ export function TicketDetail() {

interface TicketInfoProps {
ticket: {
id: string;
nid: number;
title: string;
status: number;
Expand Down Expand Up @@ -225,6 +227,7 @@ function TicketInfo({
}
onBack={onBack}
extra={[
<TicketViewers key="viewers" ticket={ticket} />,
<PrivateSelect
key="private"
value={ticket.private}
Expand Down
22 changes: 22 additions & 0 deletions next/web/src/App/Admin/Tickets/Ticket/components/TicketViewers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Avatar, Tooltip } from 'antd';

import { UseTicketViewersOptions, useTicketViewers } from '../hooks/useTicketViewers';

interface TicketViewersProps {
ticket: UseTicketViewersOptions;
}

export function TicketViewers({ ticket }: TicketViewersProps) {
const viewers = useTicketViewers(ticket);
return (
<Avatar.Group maxCount={4} style={{ display: 'flex' }}>
{viewers?.map((viewer) => (
<Tooltip key={viewer.id} title={viewer.nickname}>
<Avatar style={{ backgroundColor: '#' + viewer.id.slice(-12, -6) }}>
{viewer.nickname.slice(0, 1)}
</Avatar>
</Tooltip>
))}
</Avatar.Group>
);
}
55 changes: 55 additions & 0 deletions next/web/src/App/Admin/Tickets/Ticket/hooks/useTicketViewers.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(`/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]);
}

0 comments on commit e045295

Please sign in to comment.