import { computed, getCurrentInstance, onBeforeUnmount, ref } from 'vue' import { teamRunApi, type TeamRun, type TeamRunPage } from '@/api' import { subscribeTeamEvents, type TeamBoardEvent } from './useTeamEvents' export function sortTeamRuns(runs: readonly TeamRun[]): TeamRun[] { return runs .map((run, index) => ({ run, index })) .sort((a, b) => { const aTime = a.run.createTime ? Date.parse(a.run.createTime) : Number.NaN const bTime = b.run.createTime ? Date.parse(b.run.createTime) : Number.NaN const safeA = Number.isFinite(aTime) ? aTime : Number.NEGATIVE_INFINITY const safeB = Number.isFinite(bTime) ? bTime : Number.NEGATIVE_INFINITY return safeB - safeA || a.index - b.index }) .map(entry => entry.run) } interface RunHistoryApi { listByTeam(teamId: string, cursor?: string): Promise get(runId: string): Promise } export interface TeamRunHistoryOptions { api?: RunHistoryApi subscribe?: typeof subscribeTeamEvents debounceMs?: number setTimeoutImpl?: (handler: () => void, delay: number) => unknown clearTimeoutImpl?: (handle: unknown) => void } function responseData(response: unknown): T { return (response as { data: T }).data } export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) { const api: RunHistoryApi = options.api ?? { listByTeam: (id, cursor) => teamRunApi.listByTeamPage(id, { ...(cursor ? { cursor } : {}), limit: 20, }), get: id => teamRunApi.get(id), } const subscribe = options.subscribe ?? subscribeTeamEvents const debounceMs = options.debounceMs ?? 250 const setTimeoutImpl = options.setTimeoutImpl ?? ((handler, delay) => globalThis.setTimeout(handler, delay)) const clearTimeoutImpl = options.clearTimeoutImpl ?? (handle => globalThis.clearTimeout(handle as number)) const runs = ref([]) const loading = ref(false) const error = ref(null) const loadingMore = ref(false) const detailLoading = ref(false) const detailError = ref(null) const teamId = ref(null) const selectedRunId = ref(null) const selectedTaskId = ref(null) const nextCursor = ref(null) const selectedRun = computed(() => runs.value.find(run => run.id === selectedRunId.value) ?? null) const refreshTimers = new Map() const runRevisions = new Map() const runRequestSequences = new Map() let generation = 0 let revision = 0 let selectionRevision = 0 let detailRequestSequence = 0 let unsubscribe: (() => void) | null = null function merge(run: TeamRun) { const next = runs.value.filter(item => item.id !== run.id) runs.value = sortTeamRuns([...next, run]) runRevisions.set(run.id, ++revision) } function mergeList(list: TeamRun[], requestRevision: number, expectedTeamId: string) { const currentById = new Map(runs.value.map(run => [run.id, run])) const merged = list .filter(run => run.teamId === expectedTeamId) .map(run => (runRevisions.get(run.id) ?? 0) > requestRevision ? currentById.get(run.id)! : run) const listedIds = new Set(list.map(run => run.id)) for (const current of runs.value) { if (!listedIds.has(current.id) && (runRevisions.get(current.id) ?? 0) > requestRevision) { merged.push(current) } } runs.value = sortTeamRuns(merged) } async function refreshRun( runId: string, expectedTeamId = teamId.value, expectedGeneration = generation, options: { silent?: boolean } = {}, ): Promise { const silent = options.silent === true const requestKey = `${silent ? 'background' : 'foreground'}:${runId}` const requestSequence = (runRequestSequences.get(requestKey) ?? 0) + 1 const requestRevision = runRevisions.get(runId) ?? 0 runRequestSequences.set(requestKey, requestSequence) const requestSelectionRevision = selectionRevision const currentDetailRequest = silent ? null : ++detailRequestSequence const isLatestRequest = () => expectedGeneration === generation && runRequestSequences.get(requestKey) === requestSequence const isLatestDetailRequest = () => !silent && expectedGeneration === generation && detailRequestSequence === currentDetailRequest && selectionRevision === requestSelectionRevision try { if (!silent) { detailLoading.value = true detailError.value = null } const response = await api.get(runId) if (!isLatestRequest()) return null const run = responseData(response) if (!expectedTeamId || run.teamId !== expectedTeamId) return null if (silent && (runRevisions.get(runId) ?? 0) > requestRevision) return run merge(run) if (isLatestDetailRequest()) detailError.value = null return run } catch (cause) { if (isLatestRequest() && isLatestDetailRequest()) { detailError.value = cause instanceof Error ? cause.message : String(cause) } return null } finally { if (isLatestDetailRequest()) detailLoading.value = false } } function scheduleRunRefresh(runId: string, expectedTeamId: string, expectedGeneration: number) { const current = refreshTimers.get(runId) if (current !== undefined) clearTimeoutImpl(current) refreshTimers.set(runId, setTimeoutImpl(() => { refreshTimers.delete(runId) void refreshRun(runId, expectedTeamId, expectedGeneration, { silent: true }) }, debounceMs)) } function onEvent(event: TeamBoardEvent, expectedGeneration: number) { if (expectedGeneration !== generation) return if (!event.event.startsWith('team_run_') && !event.event.startsWith('team_task_')) return const runId = typeof event.data.runId === 'string' ? event.data.runId : null if (runId && teamId.value) scheduleRunRefresh(runId, teamId.value, expectedGeneration) } async function open(nextTeamId: string) { const expectedGeneration = ++generation nextCursor.value = null loadingMore.value = false unsubscribe?.() unsubscribe = null refreshTimers.forEach(clearTimeoutImpl) refreshTimers.clear() teamId.value = nextTeamId runs.value = [] runRevisions.clear() runRequestSequences.clear() detailRequestSequence++ revision = 0 loading.value = true error.value = null unsubscribe = subscribe(nextTeamId, event => onEvent(event, expectedGeneration)) const requestRevision = revision try { const response = await api.listByTeam(nextTeamId) if (expectedGeneration === generation) { const payload = responseData(response) const list = Array.isArray(payload) ? payload : payload?.items ?? [] nextCursor.value = Array.isArray(payload) ? null : payload?.nextCursor ?? null mergeList(list, requestRevision, nextTeamId) } } catch (cause) { if (expectedGeneration === generation) error.value = cause instanceof Error ? cause.message : String(cause) } finally { if (expectedGeneration === generation) loading.value = false } } async function refresh() { if (teamId.value) await open(teamId.value) } async function loadMore() { const cursor = nextCursor.value const expectedTeamId = teamId.value const expectedGeneration = generation if (!cursor || !expectedTeamId || loadingMore.value) return loadingMore.value = true try { const response = await api.listByTeam(expectedTeamId, cursor) if (expectedGeneration !== generation) return const payload = responseData(response) const list = Array.isArray(payload) ? payload : payload?.items ?? [] const byId = new Map(runs.value.map(run => [run.id, run])) for (const run of list) if (!byId.has(run.id)) byId.set(run.id, run) runs.value = sortTeamRuns([...byId.values()]) nextCursor.value = Array.isArray(payload) ? null : payload?.nextCursor ?? null } catch (cause) { if (expectedGeneration === generation) error.value = cause instanceof Error ? cause.message : String(cause) } finally { if (expectedGeneration === generation) loadingMore.value = false } } function select(runId: string | null, taskId: string | null = null) { selectionRevision++ detailLoading.value = false detailError.value = null selectedRunId.value = runId selectedTaskId.value = taskId } async function ensureSelectedRunDetail(runId: string, taskId: string | null, expectedTeamId = teamId.value) { const current = runs.value.find(run => run.id === runId) if (current?.projectionCompleteness === 'full') return current const expectedSelectionRevision = selectionRevision const loaded = await refreshRun(runId, expectedTeamId) if (expectedSelectionRevision === selectionRevision && selectedRunId.value === runId && selectedTaskId.value === taskId) { selectedTaskId.value = taskId } return loaded } function close() { generation++ unsubscribe?.() unsubscribe = null refreshTimers.forEach(clearTimeoutImpl) refreshTimers.clear() teamId.value = null runs.value = [] runRevisions.clear() runRequestSequences.clear() detailRequestSequence++ loading.value = false error.value = null detailLoading.value = false detailError.value = null nextCursor.value = null select(null) } if (getCurrentInstance()) onBeforeUnmount(close) return { runs, loading, loadingMore, error, detailLoading, detailError, nextCursor, selectedRun, selectedRunId, selectedTaskId, open, refresh, loadMore, refreshRun, ensureSelectedRunDetail, select, close } }