diff --git a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx index fe6bf73ce..338102eae 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx @@ -54,7 +54,7 @@ import { TicketCard } from './components/TicketCard'; import { useMixedTicket } from './mixed-ticket'; import { TicketField_v1, useTicketFields_v1 } from './api1'; import { CustomFields } from './components/CustomFields'; -import { useTicketOpsLogs, useTicketReplies } from './timeline-data'; +import { useTimeline } from './timeline-data'; import { RecentTickets } from './components/RecentTickets'; import { Evaluation } from './components/Evaluation'; import { TicketViewers } from './components/TicketViewers'; @@ -70,20 +70,26 @@ export function TicketDetail() { restoreOnUnmount: true, }); - const { replies, fetchMoreReplies, refetchReples } = useTicketReplies(ticket?.id); - const { opsLogs, fetchMoreOpsLogs } = useTicketOpsLogs(ticket?.id); + const { + data: timeline, + isLoading: isLoadingTimeline, + isLoadingGap, + loadGap, + refetch: refetchTimeline, + loadMore: loadMoreTimeline, + } = useTimeline(ticket?.id); const { mutateAsync: createReply } = useCreateReply({ onSuccess: () => { refetch(); - fetchMoreReplies(); + loadMoreTimeline(); }, }); const { mutate: operate, isLoading: operating } = useOperateTicket({ onSuccess: () => { refetch(); - fetchMoreOpsLogs(); + loadMoreTimeline(); }, }); @@ -142,9 +148,11 @@ export function TicketDetail() { onUpdate={update} /> } - replies={replies} - opsLogs={opsLogs} - onRefetchReplies={refetchReples} + loading={isLoadingTimeline} + loadingMore={isLoadingGap} + timeline={timeline} + onRefetchReplies={refetchTimeline} + onLoadMore={loadGap} /> {ticket.author && ( diff --git a/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx b/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx index b8149d478..eddfdbad0 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx @@ -1,5 +1,5 @@ -import { ReactNode, useMemo, useRef } from 'react'; -import { Modal, Skeleton } from 'antd'; +import { ReactNode, useRef } from 'react'; +import { Button, Divider, Modal, Skeleton } from 'antd'; import cx from 'classnames'; import { ReplySchema, useDeleteReply, useUpdateReply } from '@/api/reply'; @@ -15,35 +15,32 @@ type TimelineData = | { type: 'reply'; data: ReplySchema; - createTime: number; } | { type: 'opsLog'; data: OpsLogSchema; - createTime: number; + } + | { + type: 'gap'; }; interface TimelineProps { header?: ReactNode; - replies?: ReplySchema[]; - opsLogs?: OpsLogSchema[]; + timeline?: TimelineData[]; + loading?: boolean; onRefetchReplies: () => void; + onLoadMore?: () => void; + loadingMore?: boolean; } -export function Timeline({ header, replies, opsLogs, onRefetchReplies }: TimelineProps) { - const timeline = useMemo(() => { - const timeline: TimelineData[] = []; - replies?.forEach((data) => - timeline.push({ type: 'reply', data, createTime: new Date(data.createdAt).getTime() }) - ); - opsLogs?.forEach((data) => - timeline.push({ type: 'opsLog', data, createTime: new Date(data.createdAt).getTime() }) - ); - return timeline.sort((a, b) => a.createTime - b.createTime); - }, [replies, opsLogs]); - - const loading = !replies && !opsLogs; - +export function Timeline({ + header, + timeline, + loading, + onRefetchReplies, + onLoadMore, + loadingMore, +}: TimelineProps) { const editReplyModalRef = useRef(null!); const replyRevisionsModalRef = useRef(null!); @@ -102,26 +99,37 @@ export function Timeline({ header, replies, opsLogs, onRefetchReplies }: Timelin > {header} {loading && } - {timeline.map((timeline) => { - if (timeline.type === 'reply') { - return ( - } - createTime={timeline.data.createdAt} - content={timeline.data.contentSafeHTML} - files={timeline.data.files} - isAgent={timeline.data.isCustomerService} - isInternal={timeline.data.internal} - edited={timeline.data.edited} - deleted={!!timeline.data.deletedAt} - menuItems={createMenuItems(timeline.data)} - onClickMenu={(key) => handleClickMenu(timeline.data, key)} - /> - ); - } else { - return ; + {timeline?.map((timeline) => { + switch (timeline.type) { + case 'reply': + return ( + } + createTime={timeline.data.createdAt} + content={timeline.data.contentSafeHTML} + files={timeline.data.files} + isAgent={timeline.data.isCustomerService} + isInternal={timeline.data.internal} + edited={timeline.data.edited} + deleted={!!timeline.data.deletedAt} + menuItems={createMenuItems(timeline.data)} + onClickMenu={(key) => handleClickMenu(timeline.data, key)} + /> + ); + case 'opsLog': + return ; + case 'gap': + return ( +
+ + + +
+ ); } })} diff --git a/next/web/src/App/Admin/Tickets/Ticket/timeline-data.ts b/next/web/src/App/Admin/Tickets/Ticket/timeline-data.ts index 684e96fc9..4f0216d22 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/timeline-data.ts +++ b/next/web/src/App/Admin/Tickets/Ticket/timeline-data.ts @@ -1,133 +1,326 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { useInfiniteQuery } from 'react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useMountedState } from 'react-use'; import { last } from 'lodash-es'; import { db } from '@/leancloud'; import { ReplySchema } from '@/api/reply'; import { fetchTicketReplies, fetchTicketOpsLogs, OpsLog } from '@/api/ticket'; import { useCurrentRef } from '@/utils/useCurrentRef'; +import { useEffectEvent } from '@/utils/useEffectEvent'; -function getCursor() { - const search = new URLSearchParams(window.location.search); - return search.get('timelineAfter') || undefined; +interface Reader { + read: () => Promise; + peek: () => Promise; } -export function useTicketReplies(ticketId?: string) { - const { data, fetchNextPage, refetch } = useInfiniteQuery({ - queryKey: ['TicketReplies', ticketId], - queryFn: ({ pageParam }) => { - return fetchTicketReplies(ticketId || '', { - cursor: pageParam || getCursor(), - deleted: true, - pageSize: 1000, - }); - }, - enabled: !!ticketId, - getNextPageParam: (lastPage) => last(lastPage)?.createdAt, - }); +interface FetchResult { + value: T[]; + done: boolean; +} - const replies = useMemo(() => data?.pages.flat(), [data]); - const lastReply = useRef(); - lastReply.current = last(replies); - - const fetchMoreReplies = () => { - if (lastReply.current) { - fetchNextPage({ - pageParam: lastReply.current.createdAt, - cancelRefetch: false, - }); - } else { - refetch(); +class FetchReader implements Reader { + private buffer: T[] = []; + private position = 0; + private done = false; + + constructor(private state: S, private fetch: (state: S) => Promise>) {} + + async peek() { + if (!this.done && this.position === this.buffer.length) { + const { value, done } = await this.fetch(this.state); + this.buffer = value; + this.position = 0; + this.done = done; } - }; + if (this.position < this.buffer.length) { + return this.buffer[this.position]; + } + return undefined; + } + + async read() { + const value = await this.peek(); + if (value !== undefined) { + this.position += 1; + } + return value; + } +} + +class SortReader implements Reader { + constructor(private readers: Reader[], private cmp: (v1: T, v2: T) => number) {} + + async peek() { + let minReader: Reader | undefined; + let minValue: T | undefined; + for (const reader of this.readers) { + const value = await reader.peek(); + if (value !== undefined && (minValue === undefined || this.cmp(value, minValue) < 0)) { + minReader = reader; + minValue = value; + } + } + return minValue; + } + + async read() { + let minReader: Reader | undefined; + let minValue: T | undefined; + for (const reader of this.readers) { + const value = await reader.peek(); + if (value !== undefined && (minValue === undefined || this.cmp(value, minValue) < 0)) { + minReader = reader; + minValue = value; + } + } + return minReader?.read(); + } +} + +async function take(reader: Reader, count: number) { + const result: T[] = []; + for (let i = 0; i < count; i += 1) { + const value = await reader.read(); + if (value === undefined) { + break; + } + result.push(value); + } + return result; +} + +type TimelineData = + | { + type: 'reply'; + ts: number; + data: ReplySchema; + } + | { + type: 'opsLog'; + ts: number; + data: OpsLog; + }; + +interface TimelineReaderState { + ticketId: string; + pageSize: number; + cursor?: string; + desc?: boolean; +} + +function createReplyReader(state: TimelineReaderState) { + return new FetchReader({ ...state }, async (state) => { + const { ticketId, pageSize, cursor, desc } = state; + const replies = await fetchTicketReplies(ticketId, { + cursor, + pageSize, + deleted: true, + desc, + }); + state.cursor = last(replies)?.createdAt; + return { + value: replies.map((reply) => ({ + type: 'reply', + ts: new Date(reply.createdAt).getTime(), + data: reply, + })), + done: replies.length < pageSize, + }; + }); +} + +function createOpsLogReader(state: TimelineReaderState) { + return new FetchReader({ ...state }, async (state) => { + const { ticketId, pageSize, cursor, desc } = state; + const opsLogs = await fetchTicketOpsLogs(ticketId, { + cursor, + pageSize, + desc, + }); + state.cursor = last(opsLogs)?.createdAt; + return { + value: opsLogs.map((opsLog) => ({ + type: 'opsLog', + ts: new Date(opsLog.createdAt).getTime(), + data: opsLog, + })), + done: opsLogs.length < pageSize, + }; + }); +} + +function createTimelineReader(state: TimelineReaderState) { + const replyReader = createReplyReader(state); + const opsLogReader = createOpsLogReader(state); + return state.desc + ? new SortReader([replyReader, opsLogReader], (a, b) => b.ts - a.ts) + : new SortReader([replyReader, opsLogReader], (a, b) => a.ts - b.ts); +} + +type UseTimelineData = TimelineData | { type: 'gap' }; + +const dataPageSize = 50; +const reverseDataPageSize = 50; + +export function useTimeline(ticketId?: string) { + const [timelineReader, setTimelineReader] = useState>(); + + const ticketIdRef = useCurrentRef(ticketId); + const isMounted = useMountedState(); - const onCreate = useCurrentRef(() => { - fetchMoreReplies(); + const [data, setData] = useState(); + const [reverseData, setReverseData] = useState(); + const [moreData, setMoreData] = useState(); + + const [isLoading, setIsLoading] = useState(false); + const [isLoadingGap, setIsLoadingGap] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const refetch = useEffectEvent(async () => { + const ticketId = ticketIdRef.current; + if (!ticketId) return; + + const timelineReader = createTimelineReader({ + ticketId, + pageSize: dataPageSize, + }); + const reverseTimelineReader = createTimelineReader({ + ticketId, + pageSize: reverseDataPageSize, + desc: true, + }); + + setTimelineReader(timelineReader); + + try { + setIsLoading(true); + const data = await take(timelineReader, dataPageSize); + const reverseData = + data.length === dataPageSize + ? await take(reverseTimelineReader, reverseDataPageSize) + : undefined; + if (ticketId === ticketIdRef.current && isMounted()) { + setMoreData(undefined); + setData(data); + setReverseData(reverseData); + } + } finally { + if (ticketId === ticketIdRef.current && isMounted()) { + setIsLoading(false); + } + } }); - const onUpdate = useCurrentRef(() => { + useEffect(() => { refetch(); + }, [ticketId]); + + const loadGap = useEffectEvent(async () => { + if (isLoadingGap) return; + if (!data?.length || !reverseData?.length) return; + if (last(data)!.ts >= last(reverseData)!.ts) return; + if (!timelineReader) return; + const ticketId = ticketIdRef.current; + try { + setIsLoadingGap(true); + const newData = await take(timelineReader, dataPageSize); + if (ticketId === ticketIdRef.current && isMounted()) { + setData((data) => [...(data || []), ...newData]); + } + } finally { + if (ticketId === ticketIdRef.current && isMounted()) { + setIsLoadingGap(false); + } + } + }); + + const loadMore = useEffectEvent(async () => { + if (isLoading || isLoadingMore) return; + const lastItem = last(moreData) || reverseData?.[0] || last(data); + if (!lastItem) { + refetch(); + return; + } + const ticketId = ticketIdRef.current; + if (!ticketId) return; + try { + setIsLoadingMore(true); + const cursor = lastItem.data.createdAt; + const reader = createTimelineReader({ ticketId, cursor, pageSize: 10 }); + const data = await take(reader, 10); + if (ticketId === ticketIdRef.current && isMounted()) { + setMoreData((prev) => [...(prev || []), ...data]); + } + } finally { + if (ticketId === ticketIdRef.current && isMounted()) { + setIsLoadingMore(false); + } + } }); + const combinedData = useMemo(() => { + let combinedData: UseTimelineData[] | undefined = data; + if (data?.length && reverseData?.length) { + const lastDataTs = last(data)!.ts; + const overlapIdx = reverseData.findIndex((v) => v.ts <= lastDataTs); + if (overlapIdx === -1) { + combinedData = [...data, { type: 'gap' }, ...reverseData.slice().reverse()]; + } else { + combinedData = [...data, ...reverseData.slice(0, overlapIdx).reverse()]; + } + } + if (moreData?.length) { + combinedData = [...(combinedData || []), ...moreData]; + } + return combinedData; + }, [data, reverseData, moreData]); + + // Reply subscription useEffect(() => { if (!ticketId) { return; } - let mounted = true; const subscription = db .query('Reply') .where('ticket', '==', db.class('Ticket').object(ticketId)) .subscribe(); subscription.then((s) => { - if (mounted) { - s.on('create', () => onCreate.current()); - s.on('update', () => onUpdate.current()); + if (isMounted()) { + s.on('create', () => loadMore()); + s.on('update', () => refetch()); } }); return () => { subscription.then((s) => s.unsubscribe()); - mounted = false; }; }, [ticketId]); - return { - replies, - fetchMoreReplies, - refetchReples: refetch, - }; -} - -export function useTicketOpsLogs(ticketId?: string) { - const { data, fetchNextPage, refetch } = useInfiniteQuery({ - queryKey: ['TicketOpsLogs', ticketId], - queryFn: ({ pageParam }) => { - return fetchTicketOpsLogs(ticketId || '', { - cursor: pageParam || getCursor(), - pageSize: 1000, - }); - }, - enabled: !!ticketId, - getNextPageParam: (lastPage) => last(lastPage)?.createdAt, - }); - - const opsLogs = useMemo(() => data?.pages.flat(), [data]); - const lastOpsLog = useRef(); - lastOpsLog.current = last(opsLogs); - - const fetchMoreOpsLogs = () => { - if (lastOpsLog.current) { - fetchNextPage({ - pageParam: lastOpsLog.current.createdAt, - cancelRefetch: false, - }); - } else { - refetch(); - } - }; - - const onCreate = useCurrentRef(() => { - fetchMoreOpsLogs(); - }); - + // OpsLog subscription useEffect(() => { if (!ticketId) { return; } - let mounted = true; const subscription = db .query('OpsLog') .where('ticket', '==', db.class('Ticket').object(ticketId)) .subscribe(); subscription.then((s) => { - if (mounted) { - s.on('create', () => onCreate.current()); + if (isMounted()) { + s.on('create', () => loadMore()); } }); return () => { - mounted = false; subscription.then((s) => s.unsubscribe()); }; }, [ticketId]); - return { opsLogs, fetchMoreOpsLogs }; + return { + data: combinedData, + isLoading: !data && isLoading, + isLoadingGap, + isLoadingMore, + refetch, + loadGap, + loadMore, + }; } diff --git a/next/web/src/api/ticket.ts b/next/web/src/api/ticket.ts index 4636f4585..b6e2cd16e 100644 --- a/next/web/src/api/ticket.ts +++ b/next/web/src/api/ticket.ts @@ -193,17 +193,19 @@ interface FetchTicketRepliesOptions { cursor?: string; deleted?: boolean; pageSize?: number; + desc?: boolean; } export async function fetchTicketReplies( id: string, - { cursor, deleted, pageSize }: FetchTicketRepliesOptions = {} + { cursor, deleted, pageSize, desc }: FetchTicketRepliesOptions = {} ): Promise { const { data } = await http.get(`/api/2/tickets/${id}/replies`, { params: { cursor, deleted: deleted ? 1 : undefined, pageSize, + orderBy: `createdAt-${desc ? 'desc' : 'asc'}`, }, }); return data; @@ -506,16 +508,18 @@ export type OpsLog = BaseOpsLog & interface FetchOpsLogsOptions { cursor?: string; pageSize?: number; + desc?: boolean; } export async function fetchTicketOpsLogs( ticketId: string, - { cursor, pageSize }: FetchOpsLogsOptions = {} + { cursor, pageSize, desc }: FetchOpsLogsOptions = {} ) { const res = await http.get(`/api/2/tickets/${ticketId}/ops-logs`, { params: { cursor, pageSize, + orderBy: `createdAt-${desc ? 'desc' : 'asc'}`, }, }); return res.data; diff --git a/next/web/src/utils/useEffectEvent.ts b/next/web/src/utils/useEffectEvent.ts new file mode 100644 index 000000000..5a9599cf0 --- /dev/null +++ b/next/web/src/utils/useEffectEvent.ts @@ -0,0 +1,7 @@ +import { useCallback, useRef } from 'react'; + +export function useEffectEvent any>(callback: T) { + const callbackRef = useRef(callback); + callbackRef.current = callback; + return useCallback((...args: any[]) => callbackRef.current(...args), []) as T; +}