fix: stream long-running website operations
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user