From 1241d11c0105f00936d3e42185896d280904d5ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 3 Aug 2026 18:15:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/platformExportTasks.test.ts | 41 +++++ src/api/platformExportTasks.ts | 82 +++++++++ src/navigation/adminNavigation.test.ts | 1 + src/navigation/adminNavigation.ts | 2 + src/router/index.test.ts | 6 + src/router/index.ts | 6 + src/views/CustomersView.test.ts | 22 ++- src/views/CustomersView.vue | 29 +-- src/views/DownloadCenterView.test.ts | 88 ++++++++++ src/views/DownloadCenterView.vue | 224 ++++++++++++++++++++++++ src/views/EnterpriseReviewsView.test.ts | 7 +- src/views/EnterpriseReviewsView.vue | 173 +++++++++++++++--- 12 files changed, 637 insertions(+), 44 deletions(-) create mode 100644 src/api/platformExportTasks.test.ts create mode 100644 src/api/platformExportTasks.ts create mode 100644 src/views/DownloadCenterView.test.ts create mode 100644 src/views/DownloadCenterView.vue diff --git a/src/api/platformExportTasks.test.ts b/src/api/platformExportTasks.test.ts new file mode 100644 index 00000000..ee3b4978 --- /dev/null +++ b/src/api/platformExportTasks.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + createPlatformExportTask, + downloadPlatformExportTask, + listPlatformExportTasks, + retryPlatformExportTask, +} from './platformExportTasks' + +vi.mock('@/api/http', async () => { + const actual = await vi.importActual('@/api/http') + return { ...actual, http: { get: vi.fn(), post: vi.fn() } } +}) + +describe('platformExportTasks', () => { + beforeEach(() => vi.clearAllMocks()) + + it('creates a backend export task with the filter snapshot', async () => { + const task = { id: '9', status: 'PENDING', exportType: 'CUSTOMER_LIST' } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: task }) + const payload = { exportType: 'CUSTOMER_LIST', format: 'XLSX' as const, taskName: '客户列表', parameters: { status: 'ACTIVE' } } + + await expect(createPlatformExportTask(payload)).resolves.toEqual(task) + expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks', payload) + }) + + it('lists, downloads, and retries export tasks through File service', async () => { + const page = { records: [], page: '1', size: '20', total: '0', pages: '0' } + const blob = new Blob(['xlsx']) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: page }).mockResolvedValueOnce(blob) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '9', status: 'PENDING' } }) + + await expect(listPlatformExportTasks({ page: 1, size: 20, status: 'FAILED' })).resolves.toEqual({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + await expect(downloadPlatformExportTask('9')).resolves.toBe(blob) + await expect(retryPlatformExportTask('9')).resolves.toMatchObject({ id: '9', status: 'PENDING' }) + + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/files/export-tasks', { params: { page: 1, size: 20, status: 'FAILED' } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks/9/retry', {}) + }) +}) diff --git a/src/api/platformExportTasks.ts b/src/api/platformExportTasks.ts new file mode 100644 index 00000000..d1b0e02c --- /dev/null +++ b/src/api/platformExportTasks.ts @@ -0,0 +1,82 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED' +export type ExportTaskType = 'CUSTOMER_LIST' | string + +export interface PlatformExportTask { + id: string + taskNo: string + exportType: ExportTaskType + format: 'XLSX' | string + taskName: string + status: ExportTaskStatus + totalRows: number | null + processedRows: number + fileName: string | null + contentType: string | null + sizeBytes: number | null + failureMessage: string | null + requestedBy: string + requestedUsername: string | null + requestedAt: string + startedAt: string | null + completedAt: string | null + expiresAt: string | null +} + +export interface PlatformExportTaskPage { + records: PlatformExportTask[] + page: number + size: number + total: number + pages: number +} + +export interface PlatformExportTaskQuery { + page: number + size: number + exportType?: string + status?: ExportTaskStatus + requestedFrom?: string + requestedTo?: string +} + +export interface CreatePlatformExportTaskRequest { + exportType: ExportTaskType + format: 'XLSX' + taskName: string + parameters: Record +} + +const basePath = '/platform/v1/files/export-tasks' + +export async function createPlatformExportTask(payload: CreatePlatformExportTaskRequest) { + const response = await http.post, ApiEnvelope>(basePath, payload) + return unwrapApiResponse(response) +} + +export async function listPlatformExportTasks(query: PlatformExportTaskQuery) { + const response = await http.get, ApiEnvelope>(basePath, { + params: query, + }) + const page = unwrapApiResponse(response) + return { + ...page, + page: Number(page.page), + size: Number(page.size), + total: Number(page.total), + pages: Number(page.pages), + } +} + +export function downloadPlatformExportTask(taskId: string) { + return http.get(`${basePath}/${encodeURIComponent(taskId)}/download`, { responseType: 'blob' }) +} + +export async function retryPlatformExportTask(taskId: string) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(taskId)}/retry`, + {}, + ) + return unwrapApiResponse(response) +} diff --git a/src/navigation/adminNavigation.test.ts b/src/navigation/adminNavigation.test.ts index d56ce38f..33c1ddb5 100644 --- a/src/navigation/adminNavigation.test.ts +++ b/src/navigation/adminNavigation.test.ts @@ -21,6 +21,7 @@ describe('adminNavigation', () => { '企业审核', ]) expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司') + expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('下载中心') expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ '用户管理', '角色权限', diff --git a/src/navigation/adminNavigation.ts b/src/navigation/adminNavigation.ts index e38385b2..90f1fb77 100644 --- a/src/navigation/adminNavigation.ts +++ b/src/navigation/adminNavigation.ts @@ -4,6 +4,7 @@ import { ContactRound, ClipboardList, Database, + Download, FileCheck2, KeyRound, Settings, @@ -200,6 +201,7 @@ export const adminNavigation: AdminNavigationNode[] = [ // { key: 'general-location', title: '库位/货架管理', icon: WarehouseIcon, path: '/prototype/general/location-management' }, { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' }, { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' }, + { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, // { key: 'general-voyage', title: '船期价格表', icon: Database, path: '/prototype/general/voyage-price-list' }, ], }, diff --git a/src/router/index.test.ts b/src/router/index.test.ts index 8b961028..2d931d2e 100644 --- a/src/router/index.test.ts +++ b/src/router/index.test.ts @@ -30,6 +30,12 @@ describe('admin router access metadata', () => { expect(route?.meta.permission).toBe('platform:counterparty:list') }) + it('protects the download center with its stable list permission', () => { + const route = router.getRoutes().find((item) => item.name === 'download-center') + expect(route?.path).toBe('/downloads') + expect(route?.meta.permission).toBe('platform:download:list') + }) + it('protects enterprise reviews with the backend review menu permission', () => { const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews') diff --git a/src/router/index.ts b/src/router/index.ts index ebdb86ca..4ee836de 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -62,6 +62,12 @@ const router = createRouter({ component: () => import('@/views/PartnersView.vue'), meta: { title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' }, }, + { + path: 'downloads', + name: 'download-center', + component: () => import('@/views/DownloadCenterView.vue'), + meta: { title: '下载中心', description: '查看异步导出任务并下载已生成文件', permission: 'platform:download:list' }, + }, { path: 'system/users', name: 'users', diff --git a/src/views/CustomersView.test.ts b/src/views/CustomersView.test.ts index ce456614..b734eeff 100644 --- a/src/views/CustomersView.test.ts +++ b/src/views/CustomersView.test.ts @@ -11,6 +11,7 @@ import { uploadPlatformTenantQualification, type PlatformTenantResource, } from '@/api/platformIam' +import { createPlatformExportTask } from '@/api/platformExportTasks' import { useAccessPreviewStore } from '@/stores/accessPreview' import { useAuthStore } from '@/stores/auth' import CustomersView from './CustomersView.vue' @@ -29,6 +30,8 @@ vi.mock('@/api/platformIam', () => ({ uploadPlatformTenantQualification: vi.fn(), })) +vi.mock('@/api/platformExportTasks', () => ({ createPlatformExportTask: vi.fn() })) + class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} @@ -65,7 +68,7 @@ describe('CustomersView', () => { setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' - auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit'] + auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }], @@ -77,6 +80,7 @@ describe('CustomersView', () => { vi.mocked(uploadPlatformTenantQualification).mockResolvedValue({ qualificationId: 'qualification-1', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/files/license.png', }) + vi.mocked(createPlatformExportTask).mockResolvedValue({ id: 'export-1', status: 'PENDING' } as never) }) it('loads the complete customer detail before editing masked list fields', async () => { @@ -161,4 +165,20 @@ describe('CustomersView', () => { false, ) }) + + it('submits the current filters to the backend export service without loading every customer page', async () => { + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + const exportButton = [...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '导出Excel') + exportButton?.click() + await flushPromises() + + expect(createPlatformExportTask).toHaveBeenCalledWith({ + exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表', + parameters: { keyword: undefined, status: undefined }, + }) + expect(listPlatformTenants).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/views/CustomersView.vue b/src/views/CustomersView.vue index cc4d988b..c4992151 100644 --- a/src/views/CustomersView.vue +++ b/src/views/CustomersView.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { Building2, CheckCircle2, Download, Eye, FileCheck2, FileText, FileUp, Pencil, Plus, Power } from '@lucide/vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http' +import { createPlatformExportTask } from '@/api/platformExportTasks' import { changePlatformTenantStatus, createPlatformTenant, @@ -54,6 +55,7 @@ const access = useAccessPreviewStore() const page = ref(1) const pageSize = ref(10) const loading = ref(false) +const exportSubmitting = ref(false) const loadError = ref(null) const result = ref({ records: [] as PlatformTenantResource[], total: 0, pages: 0 }) const orderCounts = ref>({}) @@ -464,20 +466,21 @@ async function toggleStatus(row: PlatformTenantResource) { } finally { rowActionId.value = '' } } -function xmlEscape(value: unknown) { return String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } async function exportCustomers() { - loading.value = true + exportSubmitting.value = true try { - const first = await listPlatformTenants({ page: 1, size: 200, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined }) - const rows = [...first.records] - for (let current = 2; current <= first.pages; current += 1) rows.push(...(await listPlatformTenants({ page: current, size: 200, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined })).records) - const body = rows.map((row) => `${[row.tenantCode, row.tenantName, row.unifiedSocialCreditCode, row.contactName, row.contactPhone, row.salesOwnerName, row.paymentTermDays, statusMeta(row.status).label, formatDate(row.createdAt)].map((cell) => `${xmlEscape(cell)}`).join('')}`).join('') - const html = `${['客户ID','公司名称','统一社会信用代码','联系人','电话','销售人员','账期(天)','状态','入驻时间'].map((cell) => ``).join('')}${body}
${cell}
` - const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' })) - const anchor = document.createElement('a'); anchor.href = url; anchor.download = `客户列表-${new Date().toISOString().slice(0, 10)}.xls`; anchor.click(); URL.revokeObjectURL(url) - ElMessage.success(`已导出 ${rows.length} 条客户数据`) - } catch (error) { ElMessage.error(getApiErrorMessage(error, '客户数据导出失败')) } - finally { loading.value = false } + await createPlatformExportTask({ + exportType: 'CUSTOMER_LIST', + format: 'XLSX', + taskName: '客户列表', + parameters: { + keyword: filters.value.keyword.trim() || undefined, + status: filters.value.status || undefined, + }, + }) + ElMessage.success('导出任务已创建,可前往下载中心查看') + } catch (error) { ElMessage.error(getApiErrorMessage(error, '客户导出任务创建失败')) } + finally { exportSubmitting.value = false } } watch([page, pageSize], () => { void loadCustomers() }) @@ -493,7 +496,7 @@ onMounted(loadCustomers)
-

客户列表

共 {{ result.total }} 个客户
导出Excel新增客户
+

客户列表

共 {{ result.total }} 个客户
导出Excel新增客户
diff --git a/src/views/DownloadCenterView.test.ts b/src/views/DownloadCenterView.test.ts new file mode 100644 index 00000000..b3c0f48a --- /dev/null +++ b/src/views/DownloadCenterView.test.ts @@ -0,0 +1,88 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + downloadPlatformExportTask, + listPlatformExportTasks, + retryPlatformExportTask, + type PlatformExportTask, +} from '@/api/platformExportTasks' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAuthStore } from '@/stores/auth' +import DownloadCenterView from './DownloadCenterView.vue' + +vi.mock('@/api/platformExportTasks', () => ({ + downloadPlatformExportTask: vi.fn(), + listPlatformExportTasks: vi.fn(), + retryPlatformExportTask: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +const task: PlatformExportTask = { + id: '9', taskNo: 'EXP9', exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表', + status: 'SUCCEEDED', totalRows: 12, processedRows: 12, fileName: '客户列表.xlsx', + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 2048, + failureMessage: null, requestedBy: '1', requestedUsername: 'admin', requestedAt: '2026-08-03T10:00:00', + startedAt: '2026-08-03T10:00:01', completedAt: '2026-08-03T10:00:02', expiresAt: '2026-08-10T10:00:02', +} + +enableAutoUnmount(afterEach) + +describe('DownloadCenterView', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:export') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:download:list', 'platform:customer:export'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [task], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(downloadPlatformExportTask).mockResolvedValue(new Blob(['xlsx'])) + vi.mocked(retryPlatformExportTask).mockResolvedValue({ ...task, status: 'PENDING' }) + }) + + it('lists completed tasks and downloads the backend-generated artifact', async () => { + const pinia = setActivePinia(createPinia()) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:download:list', 'platform:customer:export'] + useAccessPreviewStore().initializeAfterLogin() + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.body.textContent).toContain('客户列表') + const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.trim() === '下载') + button?.click() + await flushPromises() + + expect(downloadPlatformExportTask).toHaveBeenCalledWith('9') + expect(URL.createObjectURL).toHaveBeenCalled() + }) + + it('shows retry only for failed tasks with the source export permission', async () => { + vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...task, status: 'FAILED', failureMessage: 'IAM timeout' }], page: 1, size: 20, total: 1, pages: 1 }) + const pinia = setActivePinia(createPinia()) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:download:list', 'platform:customer:export'] + useAccessPreviewStore().initializeAfterLogin() + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.trim() === '重试') + button?.click() + await flushPromises() + expect(retryPlatformExportTask).toHaveBeenCalledWith('9') + }) +}) diff --git a/src/views/DownloadCenterView.vue b/src/views/DownloadCenterView.vue new file mode 100644 index 00000000..193fb8e5 --- /dev/null +++ b/src/views/DownloadCenterView.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/src/views/EnterpriseReviewsView.test.ts b/src/views/EnterpriseReviewsView.test.ts index ec9077eb..6c901516 100644 --- a/src/views/EnterpriseReviewsView.test.ts +++ b/src/views/EnterpriseReviewsView.test.ts @@ -90,6 +90,7 @@ describe('EnterpriseReviewsView', () => { vi.mocked(listEnterpriseReviews).mockReset() vi.mocked(listTenantQualifications).mockReset() vi.mocked(downloadTenantQualification).mockReset() + vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) vi.mocked(approveEnterpriseReview).mockReset() vi.mocked(rejectEnterpriseReview).mockReset() vi.mocked(qualificationFileName).mockReset() @@ -144,8 +145,9 @@ describe('EnterpriseReviewsView', () => { await flushPromises() expect(listTenantQualifications).toHaveBeenCalledWith(pendingReview.tenantId) + expect(downloadTenantQualification).toHaveBeenCalledWith(pendingReview.tenantId, pendingQualification.documentUrl) expect(wrapper.text()).toContain('信用代码:91440300XXXXXXXXXX') - expect(buttonByText('查看文件')).toBeDefined() + expect(document.querySelector('.qualification-thumbnail img')?.getAttribute('alt')).toBe('营业执照缩略图') }) it('keeps qualification loading recoverable without using a direct file link', async () => { @@ -171,12 +173,13 @@ describe('EnterpriseReviewsView', () => { await mountView() tableButtonByText('详情')?.click() await flushPromises() - buttonByText('查看文件')?.click() + document.querySelector('.qualification-thumbnail')?.click() await flushPromises() const frame = document.querySelector('.qualification-preview iframe') expect(frame?.getAttribute('src')).toBe('blob:qualification-preview') expect(frame?.getAttribute('title')).toContain('PDF 预览') + expect(downloadTenantQualification).toHaveBeenCalledTimes(1) }) it('keeps the page recoverable when the endpoint fails', async () => { diff --git a/src/views/EnterpriseReviewsView.vue b/src/views/EnterpriseReviewsView.vue index e42f9748..ad5ccb71 100644 --- a/src/views/EnterpriseReviewsView.vue +++ b/src/views/EnterpriseReviewsView.vue @@ -1,7 +1,7 @@