fix: stream long-running website operations

This commit is contained in:
SmartUp Developer
2026-07-12 12:07:47 +08:00
parent c0b3b8276d
commit 40e75c0b51
6 changed files with 1248 additions and 273 deletions
+111
View File
@@ -67,6 +67,117 @@ export const authApi = {
me: () => api.get<{ email: string }>('/api/auth/me'),
}
// ——— NDJSON 流式读取工具 ———
export type NdjsonEvent = {
event: string
data: Record<string, unknown>
}
export type NdjsonStreamHandlers = {
onStart?: (data: Record<string, unknown>) => void
onItem?: (data: Record<string, unknown>) => void
onComplete?: (data: Record<string, unknown>) => void
onError?: (data: Record<string, unknown>) => void
onHeartbeat?: () => void
}
/**
* 读取 NDJSON 流,按 event 类型分派处理器。
* 返回 { ok, message }ok=false 表示流未正常完成(含 HTTP 错误、解析错误、断流)。
*/
export async function readNdjsonStream(
url: string,
options: RequestInit & { token?: string },
handlers: NdjsonStreamHandlers,
): Promise<{ ok: boolean; message: string }> {
const { token, ...fetchOpts } = options
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((fetchOpts.headers as Record<string, string>) || {}),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response: Response
try {
response = await fetch(url, { ...fetchOpts, headers })
} catch (e: any) {
return { ok: false, message: e.message || '网络请求失败' }
}
if (!response.ok) {
let errMsg = `请求失败 (${response.status})`
try {
const errJson = await response.json()
if (errJson?.detail) errMsg = errJson.detail
} catch {}
return { ok: false, message: errMsg }
}
const reader = response.body?.getReader()
if (!reader) {
return { ok: false, message: '无法读取响应流' }
}
const decoder = new TextDecoder()
let buffer = ''
let gotComplete = false
try {
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() || ''
for (const line of lines) {
if (!line.trim()) continue
let parsed: NdjsonEvent
try {
parsed = JSON.parse(line)
} catch {
continue
}
const { event, data } = parsed
if (event === 'start') handlers.onStart?.(data)
else if (event === 'item') handlers.onItem?.(data)
else if (event === 'complete') {
handlers.onComplete?.(data)
gotComplete = true
} else if (event === 'error') handlers.onError?.(data)
else if (event === 'heartbeat') handlers.onHeartbeat?.()
}
}
// 处理尾部缓冲
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer)
const { event, data } = parsed
if (event === 'complete') {
handlers.onComplete?.(data)
gotComplete = true
} else if (event === 'error') {
handlers.onError?.(data)
} else if (event === 'item') {
handlers.onItem?.(data)
}
} catch {}
}
if (!gotComplete) {
return { ok: false, message: '流异常中断,未获取到完整执行报告' }
}
} catch (e: any) {
return { ok: false, message: e.message || '流读取异常' }
}
return { ok: true, message: '' }
}
// ——— Upstreams ———
export type FinanceCostMode = 'usage_stats' | 'balance_delta'
+140 -147
View File
@@ -751,55 +751,80 @@
</template>
</el-dialog>
<el-dialog v-model="organizeDialog" title="一键整理分组结果" width="850px" destroy-on-close>
<div v-if="organizeMessage" style="margin-bottom:12px; font-weight:bold; color:var(--el-color-primary)">
{{ organizeMessage }}
<el-dialog
v-model="organizeDialog"
title="一键整理分组结果"
width="850px"
destroy-on-close
:show-close="!organizeLoading"
:close-on-click-modal="!organizeLoading"
:close-on-press-escape="!organizeLoading"
:before-close="handleOrganizeDialogBeforeClose"
>
<div>
<div style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; font-size: 14px;">
<div>
<span style="margin-right: 15px;">已处理: <strong style="color: var(--el-color-primary);">{{ organizeProcessedCount }}{{ organizeTotal ? '/' + organizeTotal : '' }}</strong></span>
<span style="margin-right: 15px;">成功: <strong style="color: var(--el-color-success);">{{ organizeSuccessCount }}</strong></span>
<span style="margin-right: 15px;">跳过: <strong style="color: var(--el-color-warning);">{{ organizeSkippedCount }}</strong></span>
<span style="margin-right: 15px;">失败: <strong style="color: var(--el-color-danger);">{{ organizeFailedCount }}</strong></span>
</div>
<div v-if="organizeLoading" style="color: var(--el-color-info); display: flex; align-items: center;">
<span class="is-loading" style="margin-right: 5px; display: inline-flex;"><el-icon><Refresh /></el-icon></span>
正在整理分组...
</div>
</div>
<div v-if="organizeMessage" style="margin-bottom:12px; font-weight:bold; color:var(--el-color-primary)">
{{ organizeMessage }}
</div>
<el-table :data="organizeResults" size="small" border style="width:100%; max-height: 400px; overflow-y: auto;">
<el-table-column label="目标分组" min-width="150">
<template #default="{ row }">
<div>{{ row.target_group_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.target_group_id }}</div>
</template>
</el-table-column>
<el-table-column label="来源上游/分组" min-width="165">
<template #default="{ row }">
<div>{{ row.upstream_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">{{ row.source_group_name }} (ID: {{ row.source_group_id }})</div>
</template>
</el-table-column>
<el-table-column label="上游 Key" min-width="140">
<template #default="{ row }">
<span v-if="row.key_name">{{ row.key_name }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="目标账号" min-width="150">
<template #default="{ row }">
<div v-if="row.account_id">
<div>{{ row.account_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.account_id }}</div>
</div>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">已创建</el-tag>
<el-tag v-else-if="row.status === 'recreated'" size="small" type="warning">清理后重建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else-if="row.status === 'missing_key'" size="small" type="danger">缺少 Key</el-tag>
<el-tag v-else size="small" type="danger">{{ keyStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="150">
<template #default="{ row }">
<span :class="{ 'error-text': row.status === 'failed' }">{{ row.message }}</span>
</template>
</el-table-column>
</el-table>
</div>
<el-table :data="organizeResults" size="small" border style="width:100%">
<el-table-column label="目标分组" min-width="150">
<template #default="{ row }">
<div>{{ row.target_group_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.target_group_id }}</div>
</template>
</el-table-column>
<el-table-column label="来源上游/分组" min-width="165">
<template #default="{ row }">
<div>{{ row.upstream_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">{{ row.source_group_name }} (ID: {{ row.source_group_id }})</div>
</template>
</el-table-column>
<el-table-column label="上游 Key" min-width="140">
<template #default="{ row }">
<span v-if="row.key_name">{{ row.key_name }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="目标账号" min-width="150">
<template #default="{ row }">
<div v-if="row.account_id">
<div>{{ row.account_name }}</div>
<div style="font-size:11px;color:var(--text-muted)">ID: {{ row.account_id }}</div>
</div>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.status === 'created'" size="small" type="success">已创建</el-tag>
<el-tag v-else-if="row.status === 'recreated'" size="small" type="warning">清理后重建</el-tag>
<el-tag v-else-if="row.status === 'exists'" size="small" type="info">已存在</el-tag>
<el-tag v-else-if="row.status === 'missing_key'" size="small" type="danger">缺少 Key</el-tag>
<el-tag v-else size="small" type="danger">{{ keyStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="150">
<template #default="{ row }">
<span :class="{ 'error-text': row.status === 'failed' }">{{ row.message }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="organizeDialog = false">关闭</el-button>
<el-button @click="organizeDialog = false" :disabled="organizeLoading">关闭</el-button>
</template>
</el-dialog>
@@ -1125,6 +1150,7 @@ import {
type CleanupInvalidAccountsItem,
type SetConcurrencyItem,
type SyncUpstreamModelsItem,
readNdjsonStream,
} from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
@@ -1276,6 +1302,18 @@ const organizeDialog = ref(false)
const organizeLoading = ref(false)
const organizeResults = ref<OrganizeGroupsItem[]>([])
const organizeMessage = ref('')
const organizeTotal = ref(0)
const organizeSuccessCount = computed(() => organizeResults.value.filter(r => r.status === 'created' || r.status === 'recreated' || r.status === 'exists').length)
const organizeFailedCount = computed(() => organizeResults.value.filter(r => r.status === 'failed').length)
const organizeSkippedCount = computed(() => organizeResults.value.filter(r => r.status === 'missing_key').length)
const organizeProcessedCount = computed(() => organizeResults.value.length)
function handleOrganizeDialogBeforeClose(done: () => void) {
if (organizeLoading.value) {
return
}
done()
}
const cleanupDialog = ref(false)
const cleanupLoading = ref(false)
@@ -1971,19 +2009,42 @@ async function organizeWebsiteGroups() {
return
}
organizeResults.value = []
organizeMessage.value = ''
organizeTotal.value = 0
organizeLoading.value = true
try {
const res = await websitesApi.organizeGroups(selectedWebsite.value.id)
organizeMessage.value = res.data.message
organizeResults.value = res.data.items
organizeDialog.value = true
organizeDialog.value = true
const authStore = useAuthStore()
const { ok, message } = await readNdjsonStream(
`/api/websites/${selectedWebsite.value.id}/groups/organize/stream`,
{ method: 'POST', token: authStore.token },
{
onStart(data) { organizeTotal.value = (data.total_items as number) || 0 },
onItem(data: any) { organizeResults.value.push(data) },
onComplete(data) {
organizeMessage.value = (data.message as string) || ''
if (data.success) ElMessage.success('整理完成')
else ElMessage.warning((data.message as string) || '部分账号整理失败')
},
onError(data) { throw new Error((data.message as string) || '流式整理出错') },
},
)
if (!ok) {
if (message.includes('正在执行中')) {
ElMessage.warning(message)
} else {
ElMessage.error(message || '一键整理分组失败')
}
if (organizeResults.value.length === 0) {
organizeDialog.value = false
}
} else {
// 刷新相关数据
await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()])
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '一键整理分组失败')
} finally {
organizeLoading.value = false
}
organizeLoading.value = false
}
async function openCleanupDialog() {
@@ -2127,105 +2188,37 @@ async function triggerSyncUpstreamModels() {
syncModelsDialog.value = true
let hasProcessedStart = false
let gotComplete = false
try {
const authStore = useAuthStore()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (authStore.token) {
headers['Authorization'] = `Bearer ${authStore.token}`
}
const response = await fetch(`/api/websites/${selectedWebsite.value.id}/accounts/sync-upstream-models/stream`, {
method: 'POST',
headers,
})
if (!response.ok) {
let errMsg = `请求失败 (${response.status})`
try {
const errJson = await response.json()
if (errJson?.detail) errMsg = errJson.detail
} catch {}
throw new Error(errMsg)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法读取响应流')
}
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() || ''
for (const line of lines) {
if (!line.trim()) continue
const eventObj = JSON.parse(line)
const { event, data } = eventObj
if (event === 'start') {
syncModelsTotal.value = data.total_accounts || 0
hasProcessedStart = true
} else if (event === 'item') {
syncModelsResults.value.push(data)
} else if (event === 'complete') {
syncModelsMessage.value = data.message
gotComplete = true
if (data.success) {
ElMessage.success('同步完成')
} else {
ElMessage.warning(data.message || '部分账号同步模型失败')
}
} else if (event === 'error') {
throw new Error(data.message || '流式同步出错')
}
}
}
if (buffer.trim()) {
const eventObj = JSON.parse(buffer)
const { event, data } = eventObj
if (event === 'start') {
syncModelsTotal.value = data.total_accounts || 0
const authStore = useAuthStore()
const { ok, message } = await readNdjsonStream(
`/api/websites/${selectedWebsite.value.id}/accounts/sync-upstream-models/stream`,
{ method: 'POST', token: authStore.token },
{
onStart(data) {
syncModelsTotal.value = (data.total_accounts as number) || 0
hasProcessedStart = true
} else if (event === 'item') {
syncModelsResults.value.push(data)
} else if (event === 'complete') {
syncModelsMessage.value = data.message
gotComplete = true
if (data.success) {
ElMessage.success('同步完成')
} else {
ElMessage.warning(data.message || '部分账号同步模型失败')
}
} else if (event === 'error') {
throw new Error(data.message || '流式同步出错')
}
}
},
onItem(data: any) { syncModelsResults.value.push(data) },
onComplete(data) {
syncModelsMessage.value = (data.message as string) || ''
if (data.success) ElMessage.success('同步完成')
else ElMessage.warning((data.message as string) || '部分账号同步模型失败')
},
onError() { /* handled via !ok */ },
},
)
if (!gotComplete) {
throw new Error('同步流异常中断,未获取到完整执行报告')
if (!ok) {
if (message.includes('正在执行中')) {
ElMessage.warning(message)
} else {
ElMessage.error(message || '同步上游模型失败')
}
} catch (e: any) {
ElMessage.error(e.message || '同步上游模型失败')
if (!hasProcessedStart || syncModelsResults.value.length === 0) {
syncModelsDialog.value = false
}
} finally {
syncModelsExecuting.value = false
}
syncModelsExecuting.value = false
}
async function toggleBinding(row: GroupBindingData) {