Trae Bot commited on
Commit ·
98277cb
1
Parent(s): 8336f26
Fix .gitignore excluding frontend/src/lib and add missing TS modules
Browse files- .gitignore +1 -1
- frontend/src/lib/api.ts +139 -0
- frontend/src/lib/business.ts +53 -0
- frontend/src/lib/content.ts +69 -0
- frontend/src/lib/errors.ts +42 -0
- frontend/src/lib/polling.ts +39 -0
- frontend/src/lib/resources.ts +72 -0
- frontend/src/lib/tasks.ts +193 -0
.gitignore
CHANGED
|
@@ -8,7 +8,7 @@ dist/
|
|
| 8 |
downloads/
|
| 9 |
eggs/
|
| 10 |
.eggs/
|
| 11 |
-
lib/
|
| 12 |
lib64/
|
| 13 |
parts/
|
| 14 |
sdist/
|
|
|
|
| 8 |
downloads/
|
| 9 |
eggs/
|
| 10 |
.eggs/
|
| 11 |
+
# lib/
|
| 12 |
lib64/
|
| 13 |
parts/
|
| 14 |
sdist/
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios, { AxiosError, type AxiosRequestConfig } from 'axios'
|
| 2 |
+
|
| 3 |
+
export type ApiError = {
|
| 4 |
+
status: number
|
| 5 |
+
message: string
|
| 6 |
+
data?: unknown
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export type ApiResponse<T> = {
|
| 10 |
+
code: number
|
| 11 |
+
msg: string
|
| 12 |
+
data?: T | null
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const defaultBaseUrl = 'http://localhost:8000/api/v1'
|
| 16 |
+
const baseURL = import.meta.env.VITE_API_BASE_URL || defaultBaseUrl
|
| 17 |
+
|
| 18 |
+
export const http = axios.create({
|
| 19 |
+
baseURL,
|
| 20 |
+
timeout: 30000,
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
function normalizePath(path: string) {
|
| 24 |
+
if (path.startsWith('http://') || path.startsWith('https://')) return path
|
| 25 |
+
return path.replace(/^\/+/, '')
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function extractWrappedErrorMessage(data: unknown) {
|
| 29 |
+
if (!data || typeof data !== 'object') return undefined
|
| 30 |
+
const obj = data as Record<string, unknown>
|
| 31 |
+
const msg = obj.msg
|
| 32 |
+
const code = obj.code
|
| 33 |
+
if (typeof msg === 'string' && typeof code === 'number') return `API ${code}: ${msg}`
|
| 34 |
+
if (typeof msg === 'string') return msg
|
| 35 |
+
return undefined
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function toApiError(error: unknown): ApiError {
|
| 39 |
+
if (axios.isAxiosError(error)) {
|
| 40 |
+
const axiosError = error as AxiosError
|
| 41 |
+
const status = axiosError.response?.status ?? 0
|
| 42 |
+
const wrappedMsg = extractWrappedErrorMessage(axiosError.response?.data)
|
| 43 |
+
const message =
|
| 44 |
+
wrappedMsg ||
|
| 45 |
+
(typeof axiosError.response?.data === 'string'
|
| 46 |
+
? axiosError.response.data
|
| 47 |
+
: axiosError.message)
|
| 48 |
+
|
| 49 |
+
return {
|
| 50 |
+
status,
|
| 51 |
+
message,
|
| 52 |
+
data: axiosError.response?.data,
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
status: 0,
|
| 58 |
+
message: error instanceof Error ? error.message : String(error),
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export async function apiGet<T>(path: string, config?: AxiosRequestConfig) {
|
| 63 |
+
try {
|
| 64 |
+
const res = await http.get<T>(normalizePath(path), config)
|
| 65 |
+
return res.data
|
| 66 |
+
} catch (error) {
|
| 67 |
+
throw toApiError(error)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function unwrapApiResponse<T>(value: unknown) {
|
| 72 |
+
if (!value || typeof value !== 'object') return { ok: false as const, error: '响应不是对象' }
|
| 73 |
+
const obj = value as Record<string, unknown>
|
| 74 |
+
if (typeof obj.code !== 'number' || typeof obj.msg !== 'string') {
|
| 75 |
+
return { ok: false as const, error: '响应缺少 code/msg 字段' }
|
| 76 |
+
}
|
| 77 |
+
const code = obj.code
|
| 78 |
+
const msg = obj.msg
|
| 79 |
+
const data = obj.data as T | null | undefined
|
| 80 |
+
if (code !== 200) {
|
| 81 |
+
return { ok: false as const, error: `API ${code}: ${msg}`, code, msg, data }
|
| 82 |
+
}
|
| 83 |
+
return { ok: true as const, data }
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export async function apiGetWrapped<T>(path: string, config?: AxiosRequestConfig) {
|
| 87 |
+
const raw = await apiGet<unknown>(path, config)
|
| 88 |
+
const parsed = unwrapApiResponse<T>(raw)
|
| 89 |
+
if (parsed.ok) return parsed.data as T
|
| 90 |
+
if (parsed.error === '响应缺少 code/msg 字段') return raw as T
|
| 91 |
+
const apiError: ApiError = {
|
| 92 |
+
status: 0,
|
| 93 |
+
message: parsed.error,
|
| 94 |
+
data: raw,
|
| 95 |
+
}
|
| 96 |
+
throw apiError
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export async function apiPost<TRes, TReq>(
|
| 100 |
+
path: string,
|
| 101 |
+
data: TReq,
|
| 102 |
+
config?: AxiosRequestConfig,
|
| 103 |
+
) {
|
| 104 |
+
try {
|
| 105 |
+
const res = await http.post<TRes>(normalizePath(path), data, config)
|
| 106 |
+
return res.data
|
| 107 |
+
} catch (error) {
|
| 108 |
+
throw toApiError(error)
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export async function apiPostWrapped<TRes, TReq>(
|
| 113 |
+
path: string,
|
| 114 |
+
data: TReq,
|
| 115 |
+
config?: AxiosRequestConfig,
|
| 116 |
+
) {
|
| 117 |
+
const raw = await apiPost<unknown, TReq>(path, data, config)
|
| 118 |
+
const parsed = unwrapApiResponse<TRes>(raw)
|
| 119 |
+
if (parsed.ok) return parsed.data as TRes
|
| 120 |
+
if (parsed.error === '响应缺少 code/msg 字段') return raw as TRes
|
| 121 |
+
const apiError: ApiError = {
|
| 122 |
+
status: 0,
|
| 123 |
+
message: parsed.error,
|
| 124 |
+
data: raw,
|
| 125 |
+
}
|
| 126 |
+
throw apiError
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export async function apiGetText(path: string, config?: AxiosRequestConfig) {
|
| 130 |
+
try {
|
| 131 |
+
const res = await http.get<string>(normalizePath(path), {
|
| 132 |
+
...config,
|
| 133 |
+
responseType: 'text',
|
| 134 |
+
})
|
| 135 |
+
return res.data
|
| 136 |
+
} catch (error) {
|
| 137 |
+
throw toApiError(error)
|
| 138 |
+
}
|
| 139 |
+
}
|
frontend/src/lib/business.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { apiGetWrapped } from './api'
|
| 2 |
+
|
| 3 |
+
export type GeneratedPostRecord = {
|
| 4 |
+
id: number
|
| 5 |
+
material_id: number | null
|
| 6 |
+
prompt_id: number | null
|
| 7 |
+
content: string | null
|
| 8 |
+
status: string | null
|
| 9 |
+
created_at: string | null
|
| 10 |
+
compliance_status: string | null
|
| 11 |
+
medical_risk_level: string | null
|
| 12 |
+
review_status: string | null
|
| 13 |
+
hit_words: string | null
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export type GeneratedPostListResponse = {
|
| 17 |
+
posts: GeneratedPostRecord[]
|
| 18 |
+
total: number
|
| 19 |
+
limit: number
|
| 20 |
+
offset: number
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type LeadRecord = {
|
| 24 |
+
id: number
|
| 25 |
+
interaction_id: number | null
|
| 26 |
+
contact_info: string | null
|
| 27 |
+
status: string | null
|
| 28 |
+
created_at: string | null
|
| 29 |
+
last_sync_at: string | null
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export type LeadListResponse = {
|
| 33 |
+
leads: LeadRecord[]
|
| 34 |
+
total: number
|
| 35 |
+
limit: number
|
| 36 |
+
offset: number
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export async function listGeneratedPosts(params: { limit: number; offset: number; status?: string }) {
|
| 40 |
+
const search = new URLSearchParams()
|
| 41 |
+
search.set('limit', String(params.limit))
|
| 42 |
+
search.set('offset', String(params.offset))
|
| 43 |
+
if (params.status) search.set('status', params.status)
|
| 44 |
+
return apiGetWrapped<GeneratedPostListResponse>(`business/posts?${search.toString()}`)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export async function listLeads(params: { limit: number; offset: number; status?: string }) {
|
| 48 |
+
const search = new URLSearchParams()
|
| 49 |
+
search.set('limit', String(params.limit))
|
| 50 |
+
search.set('offset', String(params.offset))
|
| 51 |
+
if (params.status) search.set('status', params.status)
|
| 52 |
+
return apiGetWrapped<LeadListResponse>(`business/leads?${search.toString()}`)
|
| 53 |
+
}
|
frontend/src/lib/content.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ApiError } from './api'
|
| 2 |
+
import { apiGetWrapped } from './api'
|
| 3 |
+
|
| 4 |
+
export type RawNoteRecord = {
|
| 5 |
+
id: number
|
| 6 |
+
source_platform: string | null
|
| 7 |
+
content: string | null
|
| 8 |
+
author: string | null
|
| 9 |
+
url: string | null
|
| 10 |
+
created_at: string | null
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export type RawNoteListResponse = {
|
| 14 |
+
notes: RawNoteRecord[]
|
| 15 |
+
total: number
|
| 16 |
+
limit: number
|
| 17 |
+
offset: number
|
| 18 |
+
query: string | null
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export type CleanedNoteRecord = {
|
| 22 |
+
id: number
|
| 23 |
+
raw_note_id: number | null
|
| 24 |
+
cleaned_content: string | null
|
| 25 |
+
created_at: string | null
|
| 26 |
+
raw_author: string | null
|
| 27 |
+
raw_url: string | null
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export type CleanedNoteListResponse = {
|
| 31 |
+
notes: CleanedNoteRecord[]
|
| 32 |
+
total: number
|
| 33 |
+
limit: number
|
| 34 |
+
offset: number
|
| 35 |
+
query: string | null
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export type ListContentParams = {
|
| 39 |
+
limit: number
|
| 40 |
+
offset: number
|
| 41 |
+
query?: string
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function buildSearch(params: ListContentParams) {
|
| 45 |
+
const search = new URLSearchParams()
|
| 46 |
+
search.set('limit', String(params.limit))
|
| 47 |
+
search.set('offset', String(params.offset))
|
| 48 |
+
const q = String(params.query || '').trim()
|
| 49 |
+
if (q) search.set('query', q)
|
| 50 |
+
return search.toString()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export async function listRawNotes(params: ListContentParams) {
|
| 54 |
+
return apiGetWrapped<RawNoteListResponse>(`content/raw-notes?${buildSearch(params)}`)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export async function listCleanedNotes(params: ListContentParams) {
|
| 58 |
+
return apiGetWrapped<CleanedNoteListResponse>(`content/cleaned-notes?${buildSearch(params)}`)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function isOrchestratorDbUnavailable(error: ApiError | null | undefined) {
|
| 62 |
+
if (!error) return false
|
| 63 |
+
if (error.status !== 503) return false
|
| 64 |
+
if (typeof error.message === 'string' && error.message.toLowerCase().includes('orchestrator db')) return true
|
| 65 |
+
const data = error.data
|
| 66 |
+
if (!data || typeof data !== 'object') return false
|
| 67 |
+
const obj = data as Record<string, unknown>
|
| 68 |
+
return obj.code === 10010 || obj.msg === 'orchestrator db unavailable'
|
| 69 |
+
}
|
frontend/src/lib/errors.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { apiGetWrapped } from './api'
|
| 2 |
+
import type { TaskRecord } from './tasks'
|
| 3 |
+
|
| 4 |
+
export type ErrorSummaryResponse = {
|
| 5 |
+
scan_limit: number
|
| 6 |
+
scanned: number
|
| 7 |
+
error_kind_counts: Record<string, number>
|
| 8 |
+
tasks: TaskRecord[]
|
| 9 |
+
total: number
|
| 10 |
+
limit: number
|
| 11 |
+
offset: number
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export type ErrorSummaryParams = {
|
| 15 |
+
scan_limit?: number
|
| 16 |
+
limit: number
|
| 17 |
+
offset: number
|
| 18 |
+
status?: string[]
|
| 19 |
+
error_kind?: string[]
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function join(values?: string[]) {
|
| 23 |
+
const parts = (values || []).map((v) => String(v).trim()).filter((v) => v !== '')
|
| 24 |
+
return parts.length ? parts.join(',') : undefined
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export async function getErrorSummary(params: ErrorSummaryParams) {
|
| 28 |
+
const search = new URLSearchParams()
|
| 29 |
+
search.set('limit', String(params.limit))
|
| 30 |
+
search.set('offset', String(params.offset))
|
| 31 |
+
|
| 32 |
+
if (typeof params.scan_limit === 'number' && Number.isFinite(params.scan_limit) && params.scan_limit > 0) {
|
| 33 |
+
search.set('scan_limit', String(Math.floor(params.scan_limit)))
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const status = join(params.status)
|
| 37 |
+
const errorKind = join(params.error_kind)
|
| 38 |
+
if (status) search.set('status', status)
|
| 39 |
+
if (errorKind) search.set('error_kind', errorKind)
|
| 40 |
+
|
| 41 |
+
return apiGetWrapped<ErrorSummaryResponse>(`errors/summary?${search.toString()}`)
|
| 42 |
+
}
|
frontend/src/lib/polling.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type PollOptions<T> = {
|
| 2 |
+
intervalMs: number
|
| 3 |
+
immediate?: boolean
|
| 4 |
+
shouldStop?: (value: T) => boolean
|
| 5 |
+
signal?: AbortSignal
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
function sleep(ms: number, signal?: AbortSignal) {
|
| 9 |
+
if (ms <= 0) return Promise.resolve()
|
| 10 |
+
return new Promise<void>((resolve, reject) => {
|
| 11 |
+
const timer = window.setTimeout(() => {
|
| 12 |
+
signal?.removeEventListener('abort', onAbort)
|
| 13 |
+
resolve()
|
| 14 |
+
}, ms)
|
| 15 |
+
|
| 16 |
+
function onAbort() {
|
| 17 |
+
window.clearTimeout(timer)
|
| 18 |
+
reject(new DOMException('Aborted', 'AbortError'))
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
if (signal) {
|
| 22 |
+
if (signal.aborted) return onAbort()
|
| 23 |
+
signal.addEventListener('abort', onAbort, { once: true })
|
| 24 |
+
}
|
| 25 |
+
})
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function poll<T>(fn: () => Promise<T>, options: PollOptions<T>) {
|
| 29 |
+
const { intervalMs, immediate = true, shouldStop, signal } = options
|
| 30 |
+
|
| 31 |
+
if (!immediate) await sleep(intervalMs, signal)
|
| 32 |
+
|
| 33 |
+
while (true) {
|
| 34 |
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
| 35 |
+
const value = await fn()
|
| 36 |
+
if (shouldStop?.(value)) return value
|
| 37 |
+
await sleep(intervalMs, signal)
|
| 38 |
+
}
|
| 39 |
+
}
|
frontend/src/lib/resources.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { apiGetWrapped, apiPostWrapped } from './api'
|
| 2 |
+
|
| 3 |
+
export type AccountSnapshotItem = {
|
| 4 |
+
id: string
|
| 5 |
+
tags: string[]
|
| 6 |
+
risk_score: number
|
| 7 |
+
enabled: boolean
|
| 8 |
+
available: boolean
|
| 9 |
+
cooldown_remaining_s: number
|
| 10 |
+
cooldown_until: string | null
|
| 11 |
+
last_error_kind: string | null
|
| 12 |
+
last_error_at: string | null
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export type AccountPoolSnapshotResponse = {
|
| 16 |
+
now: string
|
| 17 |
+
accounts: AccountSnapshotItem[]
|
| 18 |
+
seed: number
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export type SessionLightReport = {
|
| 22 |
+
id: string
|
| 23 |
+
account_id: string | null
|
| 24 |
+
has_cookie: boolean
|
| 25 |
+
has_storage_state: boolean
|
| 26 |
+
cookie_ok: boolean
|
| 27 |
+
storage_state_ok: boolean
|
| 28 |
+
cookie_reason: string | null
|
| 29 |
+
storage_state_reason: string | null
|
| 30 |
+
checked_at: string
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export type SessionPoolLightCheckResponse = {
|
| 34 |
+
now: string
|
| 35 |
+
sessions: SessionLightReport[]
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export type ProxyPoolSnapshotResponse = {
|
| 39 |
+
available_count: number
|
| 40 |
+
avg_score: number
|
| 41 |
+
ejected_total: number
|
| 42 |
+
failures_total_by_reason: Record<string, number>
|
| 43 |
+
recent_failures_by_reason: Record<string, number>
|
| 44 |
+
last_fail_reasons_by_reason: Record<string, number>
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export async function getResourceAccounts() {
|
| 48 |
+
return apiGetWrapped<AccountPoolSnapshotResponse>('resources/accounts')
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export async function getResourceSessions() {
|
| 52 |
+
return apiGetWrapped<SessionPoolLightCheckResponse>('resources/sessions')
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export async function getResourceProxies() {
|
| 56 |
+
return apiGetWrapped<ProxyPoolSnapshotResponse>('resources/proxies')
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function cooldownAccount(accountId: string, seconds: number) {
|
| 60 |
+
return apiPostWrapped<{ account_id: string, cooldown_seconds: number }, { seconds: number }>(
|
| 61 |
+
`resources/accounts/${encodeURIComponent(accountId)}/cooldown`,
|
| 62 |
+
{ seconds }
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function disableAccount(accountId: string) {
|
| 67 |
+
return apiPostWrapped<{ account_id: string, disabled: boolean }, {}>(
|
| 68 |
+
`resources/accounts/${encodeURIComponent(accountId)}/disable`,
|
| 69 |
+
{}
|
| 70 |
+
)
|
| 71 |
+
}
|
| 72 |
+
|
frontend/src/lib/tasks.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
import type { ApiError } from './api'
|
| 3 |
+
import { apiGetWrapped, apiPostWrapped, http, unwrapApiResponse } from './api'
|
| 4 |
+
|
| 5 |
+
export type TaskStatus =
|
| 6 |
+
| 'queued'
|
| 7 |
+
| 'running'
|
| 8 |
+
| 'retrying'
|
| 9 |
+
| 'fallback_running'
|
| 10 |
+
| 'waiting_rpa'
|
| 11 |
+
| 'rpa_running'
|
| 12 |
+
| 'rpa_imported'
|
| 13 |
+
| 'rpa_failed'
|
| 14 |
+
| 'risk_paused'
|
| 15 |
+
| 'succeeded'
|
| 16 |
+
| 'failed'
|
| 17 |
+
|
| 18 |
+
export type TaskError = {
|
| 19 |
+
kind?: string
|
| 20 |
+
message?: string
|
| 21 |
+
[key: string]: unknown
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export type TaskRecord = {
|
| 25 |
+
id: string
|
| 26 |
+
status: TaskStatus
|
| 27 |
+
task_type: string
|
| 28 |
+
target: string
|
| 29 |
+
payload: Record<string, unknown>
|
| 30 |
+
engine?: string | null
|
| 31 |
+
callback?: unknown
|
| 32 |
+
created: number
|
| 33 |
+
started?: number | null
|
| 34 |
+
finished?: number | null
|
| 35 |
+
retry_count: number
|
| 36 |
+
error?: TaskError | null
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export type TaskListResponse = {
|
| 40 |
+
tasks: TaskRecord[]
|
| 41 |
+
total: number
|
| 42 |
+
limit: number
|
| 43 |
+
offset: number
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export type TaskStatusResponse = {
|
| 47 |
+
task: TaskRecord
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export type TaskCreateRequest = {
|
| 51 |
+
task_type: string
|
| 52 |
+
target?: string | null
|
| 53 |
+
engine?: string | null
|
| 54 |
+
payload?: Record<string, unknown>
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export type TaskCreateResponse = {
|
| 58 |
+
task: TaskRecord
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export type TaskResultResponse = {
|
| 62 |
+
task_id: string
|
| 63 |
+
status: TaskStatus
|
| 64 |
+
raw: unknown | null
|
| 65 |
+
normalized: unknown | null
|
| 66 |
+
meta: Record<string, unknown>
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export type ListTasksParams = {
|
| 70 |
+
limit: number
|
| 71 |
+
offset: number
|
| 72 |
+
status?: string[]
|
| 73 |
+
task_type?: string[]
|
| 74 |
+
engine?: string[]
|
| 75 |
+
error_kind?: string[]
|
| 76 |
+
sort?: string
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function normalizePath(path: string) {
|
| 80 |
+
return path.replace(/^\/+/, '')
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function toApiError(error: unknown): ApiError {
|
| 84 |
+
if (axios.isAxiosError(error)) {
|
| 85 |
+
if (!error.response) {
|
| 86 |
+
return { status: 0, message: error.message }
|
| 87 |
+
}
|
| 88 |
+
const status = error.response?.status ?? 0
|
| 89 |
+
const body = error.response?.data
|
| 90 |
+
const wrapped = unwrapApiResponse<unknown>(body)
|
| 91 |
+
const message =
|
| 92 |
+
wrapped.ok || wrapped.error === '响应缺少 code/msg 字段'
|
| 93 |
+
? error.message
|
| 94 |
+
: wrapped.error
|
| 95 |
+
return { status, message, data: body }
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
status: 0,
|
| 100 |
+
message: error instanceof Error ? error.message : String(error),
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function join(values?: string[]) {
|
| 105 |
+
const parts = (values || []).map((v) => String(v).trim()).filter((v) => v !== '')
|
| 106 |
+
return parts.length ? parts.join(',') : undefined
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export async function listTasks(params: ListTasksParams) {
|
| 110 |
+
const search = new URLSearchParams()
|
| 111 |
+
search.set('limit', String(params.limit))
|
| 112 |
+
search.set('offset', String(params.offset))
|
| 113 |
+
const status = join(params.status)
|
| 114 |
+
const taskType = join(params.task_type)
|
| 115 |
+
const engine = join(params.engine)
|
| 116 |
+
const errorKind = join(params.error_kind)
|
| 117 |
+
const sort = String(params.sort || '').trim()
|
| 118 |
+
|
| 119 |
+
if (status) search.set('status', status)
|
| 120 |
+
if (taskType) search.set('task_type', taskType)
|
| 121 |
+
if (engine) search.set('engine', engine)
|
| 122 |
+
if (errorKind) search.set('error_kind', errorKind)
|
| 123 |
+
if (sort) search.set('sort', sort)
|
| 124 |
+
|
| 125 |
+
return apiGetWrapped<TaskListResponse>(`tasks?${search.toString()}`)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export async function createTask(payload: TaskCreateRequest) {
|
| 129 |
+
const res = await apiPostWrapped<TaskCreateResponse, TaskCreateRequest>('tasks', payload)
|
| 130 |
+
return res.task
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export async function retryTask(taskId: string) {
|
| 134 |
+
const res = await apiPostWrapped<TaskStatusResponse, {}>(`tasks/${encodeURIComponent(taskId)}/retry`, {})
|
| 135 |
+
return res.task
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export async function cancelTask(taskId: string) {
|
| 139 |
+
const res = await apiPostWrapped<TaskStatusResponse, {}>(`tasks/${encodeURIComponent(taskId)}/cancel`, {})
|
| 140 |
+
return res.task
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
export async function markTaskRpa(taskId: string) {
|
| 144 |
+
const res = await apiPostWrapped<TaskStatusResponse, {}>(`tasks/${encodeURIComponent(taskId)}/mark-rpa`, {})
|
| 145 |
+
return res.task
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export async function getTask(taskId: string) {
|
| 149 |
+
const res = await apiGetWrapped<TaskStatusResponse>(`tasks/${encodeURIComponent(taskId)}`)
|
| 150 |
+
return res.task
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
export async function getTaskResult(taskId: string, signal?: AbortSignal) {
|
| 154 |
+
try {
|
| 155 |
+
const res = await http.get<unknown>(normalizePath(`tasks/${encodeURIComponent(taskId)}/result`), {
|
| 156 |
+
validateStatus: () => true,
|
| 157 |
+
signal,
|
| 158 |
+
})
|
| 159 |
+
if (res.status === 409) return { ready: false as const, body: res.data }
|
| 160 |
+
if (res.status !== 200) {
|
| 161 |
+
const wrapped = unwrapApiResponse<unknown>(res.data)
|
| 162 |
+
const message =
|
| 163 |
+
wrapped.ok || wrapped.error === '响应缺少 code/msg 字段'
|
| 164 |
+
? `HTTP ${res.status}`
|
| 165 |
+
: wrapped.error
|
| 166 |
+
const apiError: ApiError = { status: res.status, message, data: res.data }
|
| 167 |
+
throw apiError
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const wrapped = unwrapApiResponse<TaskResultResponse>(res.data)
|
| 171 |
+
if (!wrapped.ok) {
|
| 172 |
+
const apiError: ApiError = { status: res.status, message: wrapped.error, data: res.data }
|
| 173 |
+
throw apiError
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
return { ready: true as const, data: wrapped.data ?? null }
|
| 177 |
+
} catch (e) {
|
| 178 |
+
if (axios.isAxiosError(e) && e.code === 'ERR_CANCELED') {
|
| 179 |
+
throw new DOMException('Aborted', 'AbortError')
|
| 180 |
+
}
|
| 181 |
+
if (
|
| 182 |
+
!!e &&
|
| 183 |
+
typeof e === 'object' &&
|
| 184 |
+
'status' in e &&
|
| 185 |
+
typeof (e as { status: unknown }).status === 'number' &&
|
| 186 |
+
'message' in e &&
|
| 187 |
+
typeof (e as { message: unknown }).message === 'string'
|
| 188 |
+
) {
|
| 189 |
+
throw e as ApiError
|
| 190 |
+
}
|
| 191 |
+
throw toApiError(e)
|
| 192 |
+
}
|
| 193 |
+
}
|