Your Name 54eaa54966 feat: 前端全面对接后端 API(Phase 1 完成)
- 新增基础设施: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>
2026-02-09 15:58:47 +08:00

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
}