- 新增基础设施:useOSSUpload Hook、SSEContext Provider、taskStageMapper 工具 - 达人端4页面:任务列表/详情/脚本上传/视频上传对接真实 API - 代理商端3页面:工作台/审核队列/审核详情对接真实 API - 品牌方端4页面:项目列表/创建项目/项目详情/Brief配置对接真实 API - 保留 USE_MOCK 开关,mock 模式下使用类型安全的 mock 数据 - 所有页面添加 loading 骨架屏、SSE 实时更新、错误处理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useEffect, useRef, useCallback, ReactNode } from 'react'
|
|
import { useAuth } from './AuthContext'
|
|
import { USE_MOCK } from './AuthContext'
|
|
import { getAccessToken } from '@/lib/api'
|
|
|
|
type SSEEventType = 'task_updated' | 'review_progress' | 'review_completed' | 'new_task' | 'review_decision'
|
|
type SSEHandler = (data: Record<string, unknown>) => void
|
|
|
|
interface SSEContextType {
|
|
subscribe: (eventType: SSEEventType, handler: SSEHandler) => () => void
|
|
}
|
|
|
|
const SSEContext = createContext<SSEContextType | undefined>(undefined)
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
|
|
|
export function SSEProvider({ children }: { children: ReactNode }) {
|
|
const { isAuthenticated } = useAuth()
|
|
const listenersRef = useRef<Map<SSEEventType, Set<SSEHandler>>>(new Map())
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
const dispatch = useCallback((eventType: SSEEventType, data: Record<string, unknown>) => {
|
|
const handlers = listenersRef.current.get(eventType)
|
|
if (handlers) {
|
|
handlers.forEach(handler => handler(data))
|
|
}
|
|
}, [])
|
|
|
|
const connect = useCallback(async () => {
|
|
if (USE_MOCK || !isAuthenticated) return
|
|
|
|
// 清除旧连接
|
|
abortRef.current?.abort()
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
try {
|
|
const token = getAccessToken()
|
|
if (!token) return
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/sse/events`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Accept': 'text/event-stream',
|
|
},
|
|
signal: controller.signal,
|
|
})
|
|
|
|
if (!response.ok || !response.body) return
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
let currentEvent = ''
|
|
let currentData = ''
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('event:')) {
|
|
currentEvent = line.slice(6).trim()
|
|
} else if (line.startsWith('data:')) {
|
|
currentData = line.slice(5).trim()
|
|
} else if (line === '' && currentEvent && currentData) {
|
|
try {
|
|
const parsed = JSON.parse(currentData)
|
|
dispatch(currentEvent as SSEEventType, parsed)
|
|
} catch {
|
|
// 忽略解析错误
|
|
}
|
|
currentEvent = ''
|
|
currentData = ''
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
// 5秒后重连
|
|
reconnectTimerRef.current = setTimeout(connect, 5000)
|
|
}
|
|
}, [isAuthenticated, dispatch])
|
|
|
|
useEffect(() => {
|
|
connect()
|
|
return () => {
|
|
abortRef.current?.abort()
|
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
|
|
}
|
|
}, [connect])
|
|
|
|
const subscribe = useCallback((eventType: SSEEventType, handler: SSEHandler): (() => void) => {
|
|
if (!listenersRef.current.has(eventType)) {
|
|
listenersRef.current.set(eventType, new Set())
|
|
}
|
|
listenersRef.current.get(eventType)!.add(handler)
|
|
|
|
return () => {
|
|
listenersRef.current.get(eventType)?.delete(handler)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<SSEContext.Provider value={{ subscribe }}>
|
|
{children}
|
|
</SSEContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSSE() {
|
|
const context = useContext(SSEContext)
|
|
if (!context) {
|
|
throw new Error('useSSE must be used within SSEProvider')
|
|
}
|
|
return context
|
|
}
|