返回 AiToEarn
useCalendarTiming.ts
根目录 / project / aitoearn-web / src / app / [lng] / accounts / components / CalendarTiming / useCalendarTiming.ts
1 import type FullCalendar from '@fullcalendar/react'
2 import type { ChannelPublishRecordItem, ChannelPublishRecordListVo } from '@/api/channels/channel.types'
3 import type { PublishRecordItem } from '@/api/platforms/publish.types'
4 import lodash from 'lodash'
5 import { create } from 'zustand'
6 import { combine } from 'zustand/middleware'
7 import { getChannelPublishRecordApi, getChannelPublishRecordsApi } from '@/api/channels/channel.api'
8 import { PublishStatus } from '@/api/platforms/publish.constants'
9 import { getDays } from '@/app/[lng]/accounts/components/CalendarTiming/calendarTiming.utils'
10 import { useAccountStore } from '@/store/account'
11
12 const PUBLISH_RECORD_POLLING_INTERVAL = 20_000
13
14 const pollingPublishStatuses = new Set<PublishStatus>([
15 PublishStatus.QUEUED,
16 PublishStatus.PUB_LOADING,
17 ])
18
19 interface CalendarTimingQueryOptions {
20 status?: number
21 dateRange?: [Date, Date]
22 }
23
24 function shouldPollPublishRecord(status: PublishStatus) {
25 return pollingPublishStatuses.has(status)
26 }
27
28 export interface ICalendarTimingStore {
29 // 日历单元格宽度
30 calendarCallWidth: number
31 // 发布记录数据,key=年月日,value=发布记录
32 recordMap: Map<string, PublishRecordItem[]>
33 // 请求发布记录loading
34 listLoading: boolean
35 // 当前日历ref
36 calendarRef?: FullCalendar
37 // 是否正在轮询
38 polling: boolean
39 // 最近一次列表查询条件
40 lastQueryOptions?: CalendarTimingQueryOptions
41 }
42
43 const store: ICalendarTimingStore = {
44 calendarCallWidth: 0,
45 recordMap: new Map(),
46 listLoading: false,
47 calendarRef: undefined,
48 polling: false,
49 lastQueryOptions: undefined,
50 }
51
52 function getStore() {
53 return lodash.cloneDeep(store)
54 }
55
56 function getPublishRecordList(data?: ChannelPublishRecordListVo) {
57 if (!data) {
58 return []
59 }
60
61 if (Array.isArray(data)) {
62 return data
63 }
64
65 if (Array.isArray(data.records)) {
66 return data.records
67 }
68
69 if (Array.isArray(data.list)) {
70 return data.list
71 }
72
73 if (Array.isArray(data.items)) {
74 return data.items
75 }
76
77 if (Array.isArray(data.rows)) {
78 return data.rows
79 }
80
81 return []
82 }
83
84 function normalizePublishRecord(record: ChannelPublishRecordItem): PublishRecordItem {
85 return {
86 option: record.option || {},
87 userId: record.userId || '',
88 flowId: record.flowId || '',
89 userTaskId: record.userTaskId || '',
90 taskId: record.taskId || record.id,
91 taskMaterialId: record.taskMaterialId || '',
92 type: record.type,
93 title: record.title || '',
94 desc: record.desc || '',
95 accountId: record.accountId || '',
96 topics: record.topics || [],
97 accountType: record.accountType,
98 uid: record.uid || '',
99 videoUrl: record.videoUrl || '',
100 coverUrl: record.coverUrl || '',
101 imgUrlList: record.imgUrlList || [],
102 publishTime: new Date(record.publishTime),
103 status: Number(record.status) as PublishStatus,
104 inQueue: record.inQueue || false,
105 dataId: record.dataId || record.platformWorkId || '',
106 workLink: record.workLink || '',
107 linkStatus: record.linkStatus,
108 linkError: record.linkError,
109 linkMeta: record.linkMeta,
110 platformWorkId: record.platformWorkId,
111 createdAt: record.createdAt || '',
112 updatedAt: record.updatedAt || '',
113 id: record.id,
114 errorMsg: record.errorMsg || '',
115 engagement: record.engagement,
116 }
117 }
118
119 function updateRecordMapItem(
120 recordMap: Map<string, PublishRecordItem[]>,
121 nextRecord: PublishRecordItem,
122 ) {
123 const nextRecordMap = new Map(recordMap)
124 let previousRecord: PublishRecordItem | undefined
125
126 nextRecordMap.forEach((recordList, dayKey) => {
127 const currentRecord = recordList.find(item => item.id === nextRecord.id)
128 if (currentRecord) {
129 previousRecord = currentRecord
130 }
131
132 nextRecordMap.set(dayKey, recordList.filter(item => item.id !== nextRecord.id))
133 })
134
135 const publishTime = getDays(nextRecord.publishTime)
136 const timeStr = publishTime.format('YYYY-MM-DD')
137 const nextList = nextRecordMap.get(timeStr) ?? []
138 nextList.push(nextRecord)
139 nextList.sort((a, b) => new Date(a.publishTime).getTime() - new Date(b.publishTime).getTime())
140 nextRecordMap.set(timeStr, nextList)
141
142 return previousRecord ? nextRecordMap : recordMap
143 }
144
145 function removeRecordMapItem(recordMap: Map<string, PublishRecordItem[]>, recordId: string) {
146 const nextRecordMap = new Map(recordMap)
147 let hasRemoved = false
148
149 nextRecordMap.forEach((recordList, dayKey) => {
150 const nextList = recordList.filter(item => item.id !== recordId)
151 if (nextList.length !== recordList.length) {
152 hasRemoved = true
153 nextRecordMap.set(dayKey, nextList)
154 }
155 })
156
157 return hasRemoved ? nextRecordMap : recordMap
158 }
159
160 export const useCalendarTiming = create(
161 combine(
162 {
163 ...getStore(),
164 },
165 (set, get, storeApi) => {
166 const methods = {
167 setCalendarCallWidth(calendarCallWidth: number) {
168 set({ calendarCallWidth })
169 },
170 setRecordMap(recordMap: Map<string, PublishRecordItem[]>) {
171 set({ recordMap })
172 },
173 setListLoading(listLoading: boolean) {
174 set({ listLoading })
175 },
176 setCalendarRef(calendarRef: FullCalendar) {
177 set({ calendarRef })
178 },
179 setPolling(polling: boolean) {
180 set({ polling })
181 },
182 removePubRecord(recordId: string) {
183 const recordMap = removeRecordMapItem(get().recordMap, recordId)
184 methods.setRecordMap(recordMap)
185 },
186 async refreshPubRecordDetail(recordId: string) {
187 const res = await getChannelPublishRecordApi(recordId)
188 if (!res || !res.data) {
189 return undefined
190 }
191
192 const newPubRecord = normalizePublishRecord(res.data)
193 const recordMap = updateRecordMapItem(get().recordMap, newPubRecord)
194 methods.setRecordMap(recordMap)
195
196 if (shouldPollPublishRecord(newPubRecord.status)) {
197 methods.queryPubTask()
198 }
199
200 return newPubRecord
201 },
202
203 // 获取发布记录数据
204 // dateRange: 可选的日期范围参数,用于周视图场景
205 async getPubRecord(options?: CalendarTimingQueryOptions) {
206 const { status, dateRange } = options || {}
207 set({ lastQueryOptions: options })
208
209 try {
210 methods.setListLoading(true)
211
212 let startDay, endDay
213
214 if (dateRange) {
215 // 使用传入的日期范围(周视图场景)
216 startDay = getDays(dateRange[0]).startOf('day')
217 endDay = getDays(dateRange[1]).endOf('day')
218 }
219 else {
220 // 使用 FullCalendar 的日期范围(月视图场景)
221 const calendarApi = get().calendarRef?.getApi()
222 const view = calendarApi?.view
223 const visibleStart = view?.activeStart
224 const visibleEnd = view?.activeEnd
225
226 if (visibleStart && visibleEnd) {
227 startDay = getDays(visibleStart).startOf('day')
228 endDay = getDays(visibleEnd).subtract(1, 'day').endOf('day')
229 }
230 else {
231 // calendarRef 不可用时(移动端、未初始化等),默认当月范围
232 startDay = getDays().startOf('month').startOf('day')
233 endDay = getDays().endOf('month').endOf('day')
234 }
235 }
236
237 const activeAccount = useAccountStore.getState().accountActive
238 const requestParams = {
239 time: [startDay.toISOString(), endDay.toISOString()] as [string, string],
240 accountType: activeAccount?.type,
241 status,
242 }
243
244 const res = await getChannelPublishRecordsApi(requestParams)
245 methods.setListLoading(false)
246 const records = getPublishRecordList(res?.data)
247
248 // 检查响应数据是否有效
249 if (!res || !res.data) {
250 console.warn('获取发布记录数据失败或数据格式不正确:', res)
251 methods.setRecordMap(new Map())
252 return
253 }
254
255 const recordMap = new Map<string, PublishRecordItem[]>()
256 // 将数据分拣到对应天中
257 records.map((record) => {
258 const v = normalizePublishRecord(record)
259 const days = getDays(v.publishTime)
260 const timeStr = days.format('YYYY-MM-DD')
261 let list = recordMap.get(timeStr)
262 if (!list) {
263 list = []
264 recordMap.set(timeStr, list)
265 }
266 list.push(v)
267 recordMap.set(timeStr, list)
268 })
269 // 对每一天的记录按照 publishTime 时间从早到晚排序
270 recordMap.forEach((v, k) => {
271 let list = recordMap.get(k)
272 if (list) {
273 list = list.sort(
274 (a, b) =>
275 new Date(a?.publishTime ?? 0).getTime()
276 - new Date(b?.publishTime ?? 0).getTime(),
277 )
278 recordMap.set(k, list)
279 }
280 })
281 methods.setRecordMap(recordMap)
282 // 获取完数据后,启动轮询检查
283 methods.queryPubTask()
284 }
285 catch (error) {
286 console.error('获取发布记录数据时发生错误:', error)
287 methods.setListLoading(false)
288 methods.setRecordMap(new Map())
289 }
290 },
291
292 async refreshCurrentPubRecords() {
293 await methods.getPubRecord(get().lastQueryOptions)
294 },
295
296 // 查询列表是否有在队列中/发布中的数据,如果有则串行轮询查询详情,直到状态完成
297 async queryPubTask() {
298 if (get().polling) {
299 return
300 }
301 methods.setPolling(true)
302
303 let pollRecord: PublishRecordItem | null = null
304 const recordMap = get().recordMap
305
306 // 遍历 recordMap 查找队列中或发布中的记录,同一时间只轮询一条
307 for (const [_dayKey, recordList] of recordMap.entries()) {
308 if (pollRecord !== null) {
309 break
310 }
311 for (const item of recordList) {
312 if (shouldPollPublishRecord(item.status)) {
313 pollRecord = item
314 break
315 }
316 }
317 }
318
319 if (pollRecord === null) {
320 methods.setPolling(false)
321 return
322 }
323
324 methods._pollQueryPubTask(pollRecord)
325 },
326
327 // 轮询查询发布任务详情
328 async _pollQueryPubTask(pubRecord: PublishRecordItem) {
329 await new Promise(resolve => setTimeout(resolve, PUBLISH_RECORD_POLLING_INTERVAL))
330
331 try {
332 const res = await getChannelPublishRecordApi(pubRecord.id)
333 if (!res || !res.data) {
334 methods.setPolling(false)
335 return
336 }
337
338 const newPubRecord = normalizePublishRecord(res.data)
339 const recordMap = updateRecordMapItem(get().recordMap, newPubRecord)
340 methods.setRecordMap(recordMap)
341
342 if (shouldPollPublishRecord(newPubRecord.status)) {
343 // 状态仍为队列中或发布中,继续轮询当前记录
344 methods._pollQueryPubTask(newPubRecord)
345 }
346 else {
347 methods.setPolling(false)
348 // 当前记录已结束,继续串行查询下一条队列中或发布中的任务
349 methods.queryPubTask()
350 }
351 }
352 catch (error) {
353 console.error('轮询查询发布任务详情时发生错误:', error)
354 methods.setPolling(false)
355 }
356 },
357
358 clear() {
359 set({
360 ...getStore(),
361 })
362 },
363 }
364 return methods
365 },
366 ),
367 )
368
368 lines TYPESCRIPT