fix(workflow): recover shared list requests (#709)

This commit is contained in:
InfinityPacer
2026-08-24 17:27:52 +08:00
committed by GitHub
parent c55e97b74e
commit 486704c6d2
3 changed files with 362 additions and 7 deletions
+51 -7
View File
@@ -33,12 +33,18 @@ const page = ref(1)
const keyword = ref(props.keyword)
const currentKey = ref(0)
// 关键字切换后,旧请求不得再提交分页数据或加载状态。
let requestGeneration = 0
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 当前列表请求是否失败;合法空数组仍使用空数据状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<WorkflowShare[]>([])
const currData = ref<WorkflowShare[]>([])
@@ -58,10 +64,13 @@ async function loadEventTypes() {
watch(
() => props.keyword,
newKeyword => {
requestGeneration++
keyword.value = newKeyword || ''
page.value = 1
dataList.value = []
loading.value = false
isRefreshed.value = false
loadError.value = false
currentKey.value++
},
)
@@ -78,6 +87,7 @@ function getParams() {
// 获取列表数据
async function fetchData({ done }: { done: any }) {
const generation = requestGeneration
try {
// 如果正在加载中,直接返回
if (loading.value) {
@@ -92,11 +102,15 @@ async function fetchData({ done }: { done: any }) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
const pageData: WorkflowShare[] = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
if (generation !== requestGeneration) {
done('ok')
return
}
loadError.value = false
currData.value = pageData
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
@@ -116,10 +130,15 @@ async function fetchData({ done }: { done: any }) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
const pageData: WorkflowShare[] = await api.get(apipath, {
params: getParams(),
})
loading.value = false
if (generation !== requestGeneration) {
done('ok')
return
}
loadError.value = false
currData.value = pageData
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
@@ -135,9 +154,19 @@ async function fetchData({ done }: { done: any }) {
}
}
} catch (error) {
if (generation !== requestGeneration) {
done('ok')
return
}
console.error(error)
isRefreshed.value = true
loadError.value = true
// 返回加载失败
done('error')
} finally {
if (generation === requestGeneration) {
loading.value = false
}
}
}
@@ -154,9 +183,24 @@ onMounted(() => {
<template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-visible px-2"
@load="fetchData"
:key="currentKey"
>
<template #loading />
<template #empty />
<template #error="{ props: retryProps }">
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
<span class="text-medium-emphasis">{{ t('common.apiRequestFailed') }}</span>
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
{{ t('common.retry') }}
</VBtn>
</div>
</template>
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
@@ -175,7 +219,7 @@ onMounted(() => {
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('workflow.noShareData')"
@@ -0,0 +1,304 @@
import type { WorkflowShare } from '@/api/types'
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status">正在加载共享工作流</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorTitle: String,
},
template: '<section role="region" aria-label="共享工作流状态">{{ errorTitle }}</section>',
})
const InfiniteScrollStub = defineComponent({
name: 'VInfiniteScroll',
emits: ['load'],
setup(_props, { emit, slots }) {
const status = ref<string>()
const load = () => emit('load', { done: (value: string) => (status.value = value) })
onMounted(load)
return () =>
h('div', { 'data-testid': 'share-scroll', 'data-status': status.value }, [
slots.default?.(),
status.value === 'error' ? slots.error?.({ props: { onClick: load }, side: 'end' }) : undefined,
])
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
getItemKey: { type: Function as PropType<(item: WorkflowShare) => string | undefined>, required: true },
items: { type: Array as PropType<WorkflowShare[]>, required: true },
},
setup(props, { slots }) {
return () =>
h(
'section',
{ 'data-testid': 'share-grid' },
props.items.flatMap(item => {
props.getItemKey(item)
return slots.default?.({ item }) ?? []
}),
)
},
})
const WorkflowShareCardStub = defineComponent({
name: 'WorkflowShareCard',
props: {
workflow: { type: Object as PropType<WorkflowShare>, required: true },
},
emits: ['delete', 'update'],
setup(props, { emit }) {
return () =>
h('article', { 'data-testid': `share-card-${props.workflow.id}` }, [
h('span', props.workflow.share_title),
h(
'button',
{
'aria-label': `删除-${props.workflow.id}`,
onClick: () => emit('delete'),
type: 'button',
},
'删除',
),
h(
'button',
{
'aria-label': `刷新-${props.workflow.id}`,
onClick: () => emit('update'),
type: 'button',
},
'刷新',
),
])
},
})
function createWorkflowShare(overrides: Partial<WorkflowShare> = {}): WorkflowShare {
return {
id: 'share-1',
share_title: '共享工作流',
share_user: '测试用户',
...overrides,
}
}
function shareRequests() {
return mocks.apiGet.mock.calls.filter(([endpoint]) => endpoint === 'workflow/shares')
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, reject, resolve }
}
async function renderList(keyword = '') {
return renderWithProviders(WorkflowShareView, {
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
VPageContentTitle: true,
WorkflowShareCard: WorkflowShareCardStub,
},
},
props: { keyword },
})
}
describe('WorkflowShareView', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
Object.defineProperty(document.body, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
})
it('loads the current page with the exact keyword and renders shared workflows', async () => {
const share = createWorkflowShare()
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint === 'workflow/shares') return [share]
throw new Error(`Unexpected GET ${endpoint}`)
})
await renderList('自动整理')
expect(await screen.findByText(share.share_title!)).toBeInTheDocument()
expect(shareRequests()).toEqual([
[
'workflow/shares',
{
params: { count: 30, name: '自动整理', page: 1 },
},
],
])
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows the legitimate empty state after a successful empty response', async () => {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint === 'workflow/shares') return []
throw new Error(`Unexpected GET ${endpoint}`)
})
await renderList()
expect(await screen.findByRole('region', { name: '共享工作流状态' })).toHaveTextContent('暂无数据')
expect(screen.getByTestId('share-scroll')).toHaveAttribute('data-status', 'empty')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('prefills consecutive pages until the viewport is full or the backend is exhausted', async () => {
Object.defineProperty(document.body, 'scrollHeight', { configurable: true, value: 600 })
const firstPage = createWorkflowShare({ share_title: '首个预加载结果' })
mocks.apiGet.mockImplementation((endpoint: string, options?: { params?: { page?: number } }) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint !== 'workflow/shares') throw new Error(`Unexpected GET ${endpoint}`)
return options?.params?.page === 1 ? [firstPage] : []
})
await renderList()
expect(await screen.findByText(firstPage.share_title!)).toBeInTheDocument()
await waitFor(() => expect(shareRequests()).toHaveLength(2))
expect(shareRequests().map(([, options]) => options)).toEqual([
{ params: { count: 30, name: '', page: 1 } },
{ params: { count: 30, name: '', page: 2 } },
])
expect(screen.getByTestId('share-scroll')).toHaveAttribute('data-status', 'empty')
})
it('leaves the loading state and retries the same page after an initial failure', async () => {
const recovered = createWorkflowShare({ share_title: '恢复后的共享工作流' })
let attempt = 0
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint === 'workflow/shares') {
attempt += 1
if (attempt === 1) return Promise.reject(new Error('network unavailable'))
return [recovered]
}
throw new Error(`Unexpected GET ${endpoint}`)
})
await renderList()
const failure = await screen.findByRole('alert')
expect(failure).toHaveTextContent('请求失败')
expect(screen.queryByRole('region', { name: '共享工作流状态' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
const retry = screen.getByRole('button', { name: '重试' })
await fireEvent.click(retry)
expect(await screen.findByText(recovered.share_title!)).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(shareRequests()).toHaveLength(2)
expect(shareRequests()[1][1]).toEqual({ params: { count: 30, name: '', page: 1 } })
})
it('keeps the newest keyword result when an older request finishes later', async () => {
const oldRequest = createDeferred<WorkflowShare[]>()
const latest = createWorkflowShare({ id: 'share-new', share_title: '新关键字结果' })
mocks.apiGet.mockImplementation((endpoint: string, options?: { params?: { name?: string } }) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint !== 'workflow/shares') throw new Error(`Unexpected GET ${endpoint}`)
if (options?.params?.name === '旧关键字') return oldRequest.promise
if (options?.params?.name === '新关键字') return [latest]
throw new Error(`Unexpected keyword ${options?.params?.name}`)
})
const { rerender } = await renderList('旧关键字')
await waitFor(() => expect(shareRequests()).toHaveLength(1))
await rerender({ keyword: '新关键字' })
expect(await screen.findByText(latest.share_title!)).toBeInTheDocument()
oldRequest.resolve([createWorkflowShare({ share_title: '迟到旧结果' })])
await oldRequest.promise
expect(screen.getByText(latest.share_title!)).toBeInTheDocument()
expect(screen.queryByText('迟到旧结果')).not.toBeInTheDocument()
})
it('ignores an older keyword failure after the newest request succeeds', async () => {
const oldRequest = createDeferred<WorkflowShare[]>()
const latest = createWorkflowShare({ id: 'share-new', share_title: '新关键字成功结果' })
mocks.apiGet.mockImplementation((endpoint: string, options?: { params?: { name?: string } }) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint !== 'workflow/shares') throw new Error(`Unexpected GET ${endpoint}`)
if (options?.params?.name === '旧关键字') return oldRequest.promise
if (options?.params?.name === '新关键字') return [latest]
throw new Error(`Unexpected keyword ${options?.params?.name}`)
})
const { rerender } = await renderList('旧关键字')
await waitFor(() => expect(shareRequests()).toHaveLength(1))
await rerender({ keyword: '新关键字' })
expect(await screen.findByText(latest.share_title!)).toBeInTheDocument()
oldRequest.reject(new Error('stale network failure'))
await waitFor(() => expect(screen.getByText(latest.share_title!)).toBeInTheDocument())
expect(screen.queryByRole('button', { name: '重试共享列表' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('removes a deleted share locally and forwards fork completion to the parent', async () => {
const share = createWorkflowShare()
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'workflow/event_types') return []
if (endpoint === 'workflow/shares') return [share]
throw new Error(`Unexpected GET ${endpoint}`)
})
const update = vi.fn()
await renderWithProviders(WorkflowShareView, {
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
VInfiniteScroll: InfiniteScrollStub,
VPageContentTitle: true,
WorkflowShareCard: WorkflowShareCardStub,
},
},
props: { onUpdate: update },
})
expect(await screen.findByText(share.share_title!)).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '刷新-share-1' }))
await fireEvent.click(screen.getByRole('button', { name: '删除-share-1' }))
expect(update).toHaveBeenCalledOnce()
expect(screen.queryByText(share.share_title!)).not.toBeInTheDocument()
})
})
+7
View File
@@ -425,6 +425,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/views/setting/AccountSettingSystem.vue',
'src/views/user/UserListView.vue',
'src/views/workflow/WorkflowListView.vue',
'src/views/workflow/WorkflowShareView.vue',
'src/views/user/UserProfileView.vue',
'src/views/reorganize/DownloadingListView.vue',
'src/views/plugin/PluginCardListView.vue',
@@ -630,6 +631,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
'src/views/workflow/WorkflowShareView.vue': {
branches: 80,
functions: 90,
lines: 90,
statements: 90,
},
'src/views/user/UserProfileView.vue': {
branches: 75,
functions: 80,