diff --git a/AGENTS.md b/AGENTS.md index 7bd02a44..3a2b2a26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ - 当前登录和部分数据仍可能是模拟实现。接入 `services/api` 时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 - 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 - 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。 -- 一期 Admin 头程订单仅服务唯一已知租户,前端固定使用 `tenantId=10001` 并且不展示租户选择或录入控件;接口仍保留租户参数以保持后续多租户扩展口。多租户选择、切换和权限范围尚未实现,后续迭代开始前不得自行放开或从本地存储推断租户。 +- Admin 维护企业所属仓库、往来单位、快递公司等主数据时,必须通过平台企业列表显式选择目标企业;不得从本地存储推断租户。默认可优先选择历史租户 `10001`(不存在时选择列表第一项),企业雪花 ID 必须按字符串透传,切换企业时不得保留上一企业的编辑、详情或导入上下文。 ## UI 与交互 diff --git a/README.md b/README.md index 6cc8fac5..3191b817 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ VITE_API_BASE_URL=/api API_PROXY_TARGET=http://8.163.30.128:9000 ``` -认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。审核通过新企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 +认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 21c35f4e..90ac08c4 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -18,6 +18,7 @@ import { import { createPlatformExportTask } from '@/modules/downloads' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' +import QueryToolbar from '@/shared/components/QueryToolbar.vue' import CustomersView from './CustomersView.vue' vi.mock('@/modules/iam', () => ({ @@ -47,7 +48,7 @@ class ResizeObserverStub implements ResizeObserver { const fullTenant: PlatformTenantResource = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED', - legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12', + legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000, @@ -83,7 +84,7 @@ describe('CustomersView', () => { setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' - auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export'] + auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }], @@ -132,6 +133,33 @@ describe('CustomersView', () => { expect(preview?.src).toBe('blob:qualification-preview') }) + it('does not count rejected or superseded history as a current qualification when resubmitting', async () => { + const rejectedTenant = { ...fullTenant, status: 'REJECTED' } + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [rejectedTenant], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(getPlatformTenant).mockResolvedValue(rejectedTenant) + vi.mocked(listTenantQualifications).mockResolvedValue([ + { + id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.png', + status: 'SUPERSEDED', reviewComment: '已替换', validTo: null, + }, + { + id: 'qualification-rejected', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/rejected-license.png', + status: 'REJECTED', reviewComment: '请重新上传', validTo: null, + }, + ]) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + expect(document.querySelector('.qualification-label [aria-label="已上传"]')).toBeNull() + expect(document.querySelector('.qualification-file-name')?.textContent).toContain('未选择文件') + expect(downloadTenantQualification).not.toHaveBeenCalled() + }) + it('shows uploaded qualifications in customer detail and opens the full preview', async () => { vi.mocked(listTenantQualifications).mockResolvedValue([{ id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', @@ -154,6 +182,52 @@ describe('CustomersView', () => { expect(document.querySelector('.qualification-preview img[alt="license.png预览"]')).not.toBeNull() }) + it('does not let an older customer detail response overwrite a newer one', async () => { + const secondTenant = { + ...fullTenant, id: '9', tenantCode: 'TEN9', tenantName: '第二家客户', legalName: '第二家客户有限公司', + } + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [fullTenant, secondTenant], page: 1, size: 10, total: 2, pages: 1, + }) + let resolveFirst!: (value: PlatformTenantResource) => void + let resolveSecond!: (value: PlatformTenantResource) => void + vi.mocked(getPlatformTenant) + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + const detailButtons = [...document.querySelectorAll('.el-table__body button')] + .filter((button) => button.textContent?.trim() === '详情') + detailButtons[0]?.click() + detailButtons[1]?.click() + + resolveSecond(secondTenant) + await flushPromises() + expect(document.querySelector('.customer-detail')?.textContent).toContain('第二家客户有限公司') + + resolveFirst(fullTenant) + await flushPromises() + const detailText = document.querySelector('.customer-detail')?.textContent + expect(detailText).toContain('第二家客户有限公司') + expect(detailText).not.toContain('柏悦物流有限公司') + }) + + it('labels replaced qualification history without presenting it as pending review', async () => { + vi.mocked(listTenantQualifications).mockResolvedValue([{ + id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.pdf', + status: 'SUPERSEDED', reviewComment: '已替换', validTo: null, + }]) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('详情')?.click() + await flushPromises() + + expect(document.querySelector('.detail-qualification-meta .el-tag')?.textContent).toContain('已替换') + }) + it('saves customer data and selected qualifications with one action', async () => { mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() @@ -183,9 +257,12 @@ describe('CustomersView', () => { }) 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] } }) + const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() + wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', { keyword: ' Acme ', status: 'ACTIVE' }) + await wrapper.vm.$nextTick() + const exportButton = [...document.querySelectorAll('button')] .find((button) => button.textContent?.trim() === '导出Excel') exportButton?.click() @@ -193,11 +270,35 @@ describe('CustomersView', () => { expect(createPlatformExportTask).toHaveBeenCalledWith({ exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表', - parameters: { keyword: undefined, status: undefined }, + parameters: { keyword: 'Acme', status: 'ACTIVE' }, }) expect(listPlatformTenants).toHaveBeenCalledTimes(1) }) + it('hides customer export when the stable export permission is absent', async () => { + const auth = useAuthStore() + auth.permissions = ['platform:customer:list', 'platform:customer:view'] + useAccessPreviewStore().initializeAfterLogin() + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect([...document.querySelectorAll('button')] + .some((button) => button.textContent?.trim() === '导出Excel')).toBe(false) + expect(createPlatformExportTask).not.toHaveBeenCalled() + }) + + it('does not create an unrecoverable export for a role without download-center access', async () => { + const auth = useAuthStore() + auth.permissions = ['platform:customer:list', 'platform:customer:export'] + useAccessPreviewStore().initializeAfterLogin() + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect([...document.querySelectorAll('button')] + .some((button) => button.textContent?.trim() === '导出Excel')).toBe(false) + expect(createPlatformExportTask).not.toHaveBeenCalled() + }) + it('approves a pending enterprise directly from the customer list', async () => { const auth = useAuthStore() auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve'] @@ -228,6 +329,35 @@ describe('CustomersView', () => { expect(document.body.textContent).toContain('Temporary#2026') }) + it('keeps the client registration account as administrator after approval', async () => { + const auth = useAuthStore() + auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve'] + useAccessPreviewStore().initializeAfterLogin() + vi.mocked(listPlatformTenants).mockResolvedValue({ + records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1, + }) + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(approveEnterpriseReview).mockResolvedValue({ + tenantId: fullTenant.id, + administratorCreated: false, + administratorUsername: null, + temporaryPassword: null, + }) + + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('通过')?.click() + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((button) => button.textContent?.trim() === '确认通过')?.click() + await flushPromises() + + expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过') + expect(rejectEnterpriseReview).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('注册账号继续作为企业管理员') + expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证') + }) + it('keeps review-only roles on the merged customer page without calling the customer list endpoint', async () => { const auth = useAuthStore() auth.permissions = ['platform:customer:review', 'platform:customer:approve'] diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index ac5f0bd4..73b37366 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -105,7 +105,7 @@ const queryFields: QueryField[] = [ ] const emptyForm = (): CreatePlatformTenantRequest => ({ - tenantName: '', legalName: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '', + tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '', contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 0, enterpriseType: '', establishedDate: null, registeredCapital: null, registeredCapitalCurrency: 'CNY', registeredAddress: '', internalRemark: '', @@ -114,6 +114,7 @@ const form = ref(emptyForm()) const rules: FormRules = { tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }], legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }], + legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }], unifiedSocialCreditCode: [ { required: true, message: '请输入统一社会信用代码', trigger: 'blur' }, { pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }, @@ -174,6 +175,8 @@ const customerColumns: DataTableColumn[] = [ let loadSequence = 0 let reviewLoadSequence = 0 +let editorLoadSequence = 0 +let detailLoadSequence = 0 const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户') const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value)) const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || '')) @@ -191,6 +194,13 @@ function statusMeta(status: PlatformTenantStatus) { return { label: '已停用', type: 'info' as const } } +function qualificationStatusMeta(status: PlatformTenantQualification['status']) { + if (status === 'APPROVED') return { label: '已通过', type: 'success' as const } + if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const } + if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const } + return { label: '待审核', type: 'warning' as const } +} + async function loadPendingReviews() { const sequence = ++reviewLoadSequence if (!access.can('platform:customer:review')) { @@ -227,6 +237,7 @@ function reviewToCustomer(review: EnterpriseReview): PlatformTenantResource { tenantName: review.tenantName?.trim() || `待审核企业(${tenantId})`, status: 'PENDING', legalName: null, + legalRepresentative: null, unifiedSocialCreditCode: null, contactName: review.contactName ?? null, contactPhone: review.contactPhone ?? null, @@ -308,12 +319,13 @@ function resetEditor() { form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = '' } function openCreate() { + editorLoadSequence += 1 editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true nextTick(() => formRef.value?.clearValidate()) } function mapTenantToForm(row: PlatformTenantResource): CreatePlatformTenantRequest { return { - tenantName: row.tenantName, legalName: row.legalName || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '', + tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '', contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '', contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '', paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null, @@ -322,12 +334,16 @@ function mapTenantToForm(row: PlatformTenantResource): CreatePlatformTenantReque internalRemark: row.internalRemark || '', } } -async function loadEditorQualifications(tenantId: number | string) { +async function loadEditorQualifications(tenantId: number | string, sequence = editorLoadSequence) { qualificationLoadError.value = '' try { const records = await listTenantQualifications(tenantId) + if (sequence !== editorLoadSequence || !editorOpen.value || editingId.value !== String(tenantId)) return + const acceptedStatus = ['ACTIVE', 'DISABLED'].includes(editorStatus.value || '') ? 'APPROVED' : 'PENDING' for (const draft of qualifications.value) { - const item = [...records].reverse().find((record) => record.qualificationType === draft.type && record.status !== 'REJECTED') + const item = [...records].reverse().find((record) => ( + record.qualificationType === draft.type && record.status === acceptedStatus + )) if (item) { draft.uploaded = true draft.validTo = item.validTo || '' @@ -341,21 +357,23 @@ async function loadEditorQualifications(tenantId: number | string) { } catch (error) { qualificationLoadError.value = getApiErrorMessage(error, '资质信息加载失败,可稍后重试') } } async function openEdit(row: PlatformTenantResource) { - editingId.value = String(row.id); editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row) + const id = String(row.id) + const sequence = ++editorLoadSequence + editingId.value = id; editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row) editorOpen.value = true; editorLoading.value = true try { const tenant = await getPlatformTenant(row.id) - if (editingId.value !== String(row.id)) return + if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return editorStatus.value = tenant.status form.value = mapTenantToForm(tenant) savedFormSnapshot.value = JSON.stringify(form.value) - await loadEditorQualifications(row.id) + await loadEditorQualifications(row.id, sequence) await nextTick(() => formRef.value?.clearValidate()) } catch (error) { - if (editingId.value === String(row.id)) editorOpen.value = false + if (sequence === editorLoadSequence && editingId.value === id) editorOpen.value = false ElMessage.error(getApiErrorMessage(error, '客户完整资料加载失败,请稍后重试')) } finally { - if (editingId.value === String(row.id)) editorLoading.value = false + if (sequence === editorLoadSequence && editingId.value === id) editorLoading.value = false } } @@ -367,7 +385,7 @@ function selectQualificationFile(event: Event, draft: QualificationDraft) { const extensionValid = /\.(pdf|jpe?g|png)$/i.test(file.name) const mimeValid = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) if (!extensionValid || !mimeValid) { draft.error = '仅支持 PDF、JPG、PNG 文件'; input.value = ''; return } - if (file.size > 10 * 1024 * 1024) { draft.error = '单个文件不能超过 10MB'; input.value = ''; return } + if (file.size > 5 * 1024 * 1024) { draft.error = '单个文件不能超过 5MB'; input.value = ''; return } draft.file = file draft.previewKind = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image' draft.previewUrl = URL.createObjectURL(file) @@ -545,10 +563,13 @@ async function submitReview() { } async function showDetail(row: PlatformTenantResource) { + const sequence = ++detailLoadSequence + const rowId = String(row.id) clearDetailQualificationPreviews() detail.value = row; detailQualifications.value = []; detailOpen.value = true; detailLoading.value = true try { const [tenant, records] = await Promise.all([getPlatformTenant(row.id), listTenantQualifications(row.id)]) + if (sequence !== detailLoadSequence || !detailOpen.value || String(detail.value?.id) !== rowId) return detail.value = tenant detailQualifications.value = records detailQualificationPreviews.value = Object.fromEntries(records.map((item) => { @@ -558,8 +579,11 @@ async function showDetail(row: PlatformTenantResource) { for (const item of records) { if (detailQualificationPreviews.value[String(item.id)]?.kind === 'image') void loadDetailQualificationPreview(item, false) } - } catch (error) { ElMessage.error(getApiErrorMessage(error, '客户详情加载失败')) } - finally { detailLoading.value = false } + } catch (error) { + if (sequence === detailLoadSequence) ElMessage.error(getApiErrorMessage(error, '客户详情加载失败')) + } finally { + if (sequence === detailLoadSequence) detailLoading.value = false + } } async function toggleStatus(row: PlatformTenantResource) { const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用' @@ -601,7 +625,12 @@ async function submitDecision() { } decisionOpen.value = false detailOpen.value = false - ElMessage.success(decision.type === 'approve' ? '企业资料已审核通过' : '企业资料已驳回') + const successMessage = decision.type === 'reject' + ? '企业资料已驳回' + : approved?.administratorCreated + ? '企业资料已审核通过,管理员账号已创建' + : '企业资料已审核通过,注册账号继续作为企业管理员' + ElMessage.success(successMessage) if (approved?.administratorCreated) { approvalResult.value = approved approvalResultOpen.value = true @@ -637,8 +666,18 @@ async function exportCustomers() { } watch([page, pageSize], () => { void loadCustomers() }) -watch(editorOpen, (open) => { if (!open) clearQualificationPreviews() }) -watch(detailOpen, (open) => { if (!open) clearDetailQualificationPreviews() }) +watch(editorOpen, (open) => { + if (!open) { + editorLoadSequence += 1 + clearQualificationPreviews() + } +}) +watch(detailOpen, (open) => { + if (!open) { + detailLoadSequence += 1 + clearDetailQualificationPreviews() + } +}) watch( () => [access.can('platform:customer:approve'), access.can('platform:customer:reject')], ([canApprove, canReject]) => { @@ -655,7 +694,7 @@ onMounted(loadCustomers)
-

客户列表

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

客户列表

共 {{ result.total }} 个客户
导出Excel新增客户
@@ -670,6 +709,7 @@ onMounted(loadCustomers)

基础资料

+ @@ -703,7 +743,7 @@ onMounted(loadCustomers)
-

支持 PDF、JPG、PNG,单个文件不超过 10MB;* 为提交审核必填项。

+

支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。

@@ -740,6 +780,8 @@ onMounted(loadCustomers)

企业资料

+
营业执照企业名称
{{ displayText(detail.legalName) }}
+
法定代表人
{{ displayText(detail.legalRepresentative) }}
统一社会信用代码
{{ displayText(detail.unifiedSocialCreditCode) }}
联系人
{{ displayText(detail.contactName) }}
联系电话
{{ displayText(detail.contactPhone) }}
@@ -763,7 +805,7 @@ onMounted(loadCustomers) PDF {{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}{{ detailQualificationName(item) }}{{ item.validTo ? `有效期至 ${item.validTo}` : '长期有效或未设置有效期' }} - {{ item.status === 'APPROVED' ? '已通过' : item.status === 'REJECTED' ? '已驳回' : '待审核' }}{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }} + {{ qualificationStatusMeta(item.status).label }}{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }}
暂未上传资质文件
diff --git a/src/modules/downloads/api/platformExportTasks.test.ts b/src/modules/downloads/api/platformExportTasks.test.ts index 54660c51..bdec8d78 100644 --- a/src/modules/downloads/api/platformExportTasks.test.ts +++ b/src/modules/downloads/api/platformExportTasks.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { http } from '@/shared/api/http' +import { ApiResponseError, http } from '@/shared/api/http' import { createPlatformExportTask, downloadPlatformExportTask, + getPlatformExportTask, listPlatformExportTasks, retryPlatformExportTask, } from './platformExportTasks' @@ -24,18 +25,97 @@ describe('platformExportTasks', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks', payload) }) - it('lists, downloads, and retries export tasks through File service', async () => { + it('lists, reads, downloads, and retries export tasks through File service', async () => { const page = { records: [], page: '1', size: '20', total: '0', pages: '0' } + const task = { id: '9', status: 'SUCCEEDED' } const blob = new Blob(['xlsx']) - vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: page }).mockResolvedValueOnce(blob) + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '200', message: 'success', data: page }) + .mockResolvedValueOnce({ code: '200', message: 'success', data: task }) + .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(getPlatformExportTask('9')).resolves.toEqual(task) 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.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9') + expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob', timeout: 600_000 }) expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks/9/retry', {}) }) + + it('parses a Blob error envelope and preserves backend trace metadata', async () => { + vi.mocked(http.get).mockRejectedValue({ + isAxiosError: true, + response: { + status: 410, + data: new Blob([JSON.stringify({ + code: 'FILE_EXPIRED', message: '导出文件已过期', data: null, + requestId: 'request-download-1', traceId: 'trace-download-1', + })], { type: 'application/json' }), + }, + }) + + const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ + message: '导出文件已过期', code: 'FILE_EXPIRED', status: 410, + requestId: 'request-download-1', traceId: 'trace-download-1', + }) + }) + + it('rejects an HTTP 200 business-error Blob instead of saving it as XLSX', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ + code: 'FILE_EXPIRED', message: '导出文件已过期', data: null, + requestId: 'request-download-200', traceId: 'trace-download-200', + })], { type: 'application/json;charset=UTF-8' })) + + const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ + message: '导出文件已过期', code: 'FILE_EXPIRED', status: 200, + requestId: 'request-download-200', traceId: 'trace-download-200', + }) + }) + + it('rejects malformed JSON download responses instead of saving them as XLSX', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob(['{not-json'], { type: 'application/json' })) + + const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ + message: '下载接口返回了无法识别的 JSON 响应', + code: 'INVALID_DOWNLOAD_RESPONSE', + status: 200, + }) + }) + + it('rejects valid JSON values that are not API envelopes', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob(['null'], { type: 'application/json' })) + + const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ + message: '下载接口返回了无法识别的 JSON 响应', + code: 'INVALID_DOWNLOAD_RESPONSE', + status: 200, + }) + }) + + it('rejects oversized JSON download responses without parsing or saving them', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob( + ['x'.repeat(1024 * 1024 + 1)], + { type: 'application/problem+json' }, + )) + + const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(ApiResponseError) + expect(error).toMatchObject({ + message: '下载接口返回了超出限制的 JSON 响应', + code: 'INVALID_DOWNLOAD_RESPONSE', + status: 200, + }) + }) }) diff --git a/src/modules/downloads/api/platformExportTasks.ts b/src/modules/downloads/api/platformExportTasks.ts index f6f3206c..ff52f809 100644 --- a/src/modules/downloads/api/platformExportTasks.ts +++ b/src/modules/downloads/api/platformExportTasks.ts @@ -1,4 +1,5 @@ -import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import axios from 'axios' +import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED' export type ExportTaskType = 'CUSTOMER_LIST' | string @@ -49,6 +50,45 @@ export interface CreatePlatformExportTaskRequest { } const basePath = '/platform/v1/files/export-tasks' +const downloadTimeoutMs = 10 * 60 * 1000 +const maxErrorEnvelopeBytes = 1024 * 1024 + +async function parseDownloadErrorBlob(blob: Blob, status?: number): Promise { + if (!blob.type.toLowerCase().includes('json')) return null + + if (blob.size > maxErrorEnvelopeBytes) { + return new ApiResponseError( + '下载接口返回了超出限制的 JSON 响应', + 'INVALID_DOWNLOAD_RESPONSE', + status, + ) + } + + try { + const payload = JSON.parse(await blob.text()) as Partial> + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return new ApiResponseError( + '下载接口返回了无法识别的 JSON 响应', + 'INVALID_DOWNLOAD_RESPONSE', + status, + ) + } + return new ApiResponseError( + payload.code === '200' ? '下载接口未返回文件内容' : payload.message || '文件下载失败', + payload.code === '200' ? 'INVALID_DOWNLOAD_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'DOWNLOAD_FAILED'), + status, + payload.data, + payload.requestId, + payload.traceId, + ) + } catch { + return new ApiResponseError( + '下载接口返回了无法识别的 JSON 响应', + 'INVALID_DOWNLOAD_RESPONSE', + status, + ) + } +} export async function createPlatformExportTask(payload: CreatePlatformExportTaskRequest) { const response = await http.post, ApiEnvelope>(basePath, payload) @@ -69,8 +109,30 @@ export async function listPlatformExportTasks(query: PlatformExportTaskQuery) { } } -export function downloadPlatformExportTask(taskId: string) { - return http.get(`${basePath}/${encodeURIComponent(taskId)}/download`, { responseType: 'blob' }) +export async function getPlatformExportTask(taskId: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${encodeURIComponent(taskId)}`, + ) + return unwrapApiResponse(response) +} + +export async function downloadPlatformExportTask(taskId: string) { + try { + const blob = await http.get(`${basePath}/${encodeURIComponent(taskId)}/download`, { + responseType: 'blob', + // 导出文件上限 50 MB,不能继承普通接口的 15 秒超时,也不能无限等待半断开的连接。 + timeout: downloadTimeoutMs, + }) + const responseError = await parseDownloadErrorBlob(blob, 200) + if (responseError) throw responseError + return blob + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const responseError = await parseDownloadErrorBlob(error.response.data, error.response.status) + if (responseError) throw responseError + } + throw error + } } export async function retryPlatformExportTask(taskId: string) { diff --git a/src/modules/downloads/pages/DownloadCenterView.test.ts b/src/modules/downloads/pages/DownloadCenterView.test.ts index 8d2fe01a..a2709323 100644 --- a/src/modules/downloads/pages/DownloadCenterView.test.ts +++ b/src/modules/downloads/pages/DownloadCenterView.test.ts @@ -4,6 +4,7 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { downloadPlatformExportTask, + getPlatformExportTask, listPlatformExportTasks, retryPlatformExportTask, type PlatformExportTask, @@ -14,6 +15,7 @@ import DownloadCenterView from './DownloadCenterView.vue' vi.mock('@/modules/downloads/api/platformExportTasks', () => ({ downloadPlatformExportTask: vi.fn(), + getPlatformExportTask: vi.fn(), listPlatformExportTasks: vi.fn(), retryPlatformExportTask: vi.fn(), })) @@ -38,6 +40,7 @@ afterEach(() => vi.useRealTimers()) describe('DownloadCenterView', () => { beforeEach(() => { vi.clearAllMocks() + document.body.innerHTML = '' vi.stubGlobal('ResizeObserver', ResizeObserverStub) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:export') }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) @@ -50,6 +53,7 @@ describe('DownloadCenterView', () => { 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(getPlatformExportTask).mockResolvedValue(task) vi.mocked(retryPlatformExportTask).mockResolvedValue({ ...task, status: 'PENDING' }) }) @@ -70,6 +74,11 @@ describe('DownloadCenterView', () => { expect(downloadPlatformExportTask).toHaveBeenCalledWith('9') expect(URL.createObjectURL).toHaveBeenCalled() + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce() + const clickedAnchor = vi.mocked(HTMLAnchorElement.prototype.click).mock.instances[0] as HTMLAnchorElement + expect(clickedAnchor.download).toBe('客户列表.xlsx') + await new Promise((resolve) => window.setTimeout(resolve, 0)) + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:export') }) it('shows retry only for failed tasks with the source export permission', async () => { @@ -89,6 +98,83 @@ describe('DownloadCenterView', () => { expect(retryPlatformExportTask).toHaveBeenCalledWith('9') }) + it('loads a fresh task detail from the backend', async () => { + const wrapper = mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + + const button = [...document.querySelectorAll('button')] + .find((item) => item.textContent?.trim() === '详情') + button?.click() + await flushPromises() + + expect(getPlatformExportTask).toHaveBeenCalledWith('9') + expect(document.body.textContent).toContain('任务详情 · EXP9') + expect(document.body.textContent).toContain('客户列表.xlsx') + wrapper.unmount() + }) + + it('renders an actionable list error and can recover', async () => { + vi.mocked(listPlatformExportTasks) + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ records: [task], page: 1, size: 20, total: 1, pages: 1 }) + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + + expect(document.querySelector('[role="alert"]')?.textContent).toContain('下载任务暂时无法加载') + const reload = [...document.querySelectorAll('button')] + .find((item) => item.textContent?.trim() === '重新加载') + reload?.click() + await flushPromises() + + expect(listPlatformExportTasks).toHaveBeenCalledTimes(2) + expect(document.body.textContent).toContain('共 1 个任务') + }) + + it('shows a clear empty state and hides retry without the source export permission', async () => { + vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + const pinia = setActivePinia(createPinia()) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = ['platform:download:list'] + useAccessPreviewStore().initializeAfterLogin() + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.body.textContent).toContain('暂无符合筛选条件的下载任务') + expect([...document.querySelectorAll('button')] + .some((item) => item.textContent?.trim() === '重试')).toBe(false) + }) + + it('does not expose customer retry without the customer 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'] + useAccessPreviewStore().initializeAfterLogin() + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(document.body.textContent).toContain('IAM timeout') + expect([...document.querySelectorAll('button')] + .some((item) => item.textContent?.trim() === '重试')).toBe(false) + }) + + it('announces the initial loading state while the task request is pending', async () => { + let resolveRequest!: (value: { records: PlatformExportTask[]; page: number; size: number; total: number; pages: number }) => void + vi.mocked(listPlatformExportTasks).mockReturnValue(new Promise((resolve) => { resolveRequest = resolve })) + const wrapper = mount(DownloadCenterView, { global: { plugins: [createPinia(), ElementPlus] } }) + await wrapper.vm.$nextTick() + + expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('true') + resolveRequest({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + await flushPromises() + expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('false') + }) + it('does not schedule another refresh after an in-flight load finishes on an unmounted page', async () => { vi.useFakeTimers() let resolveRequest!: (value: { records: PlatformExportTask[]; page: number; size: number; total: number; pages: number }) => void @@ -102,4 +188,42 @@ describe('DownloadCenterView', () => { expect(listPlatformExportTasks).toHaveBeenCalledTimes(1) }) + + it('keeps the last successful task list and continues polling after a background refresh failure', async () => { + vi.useFakeTimers() + vi.mocked(listPlatformExportTasks) + .mockResolvedValueOnce({ records: [{ ...task, status: 'RUNNING', processedRows: 3 }], page: 1, size: 20, total: 1, pages: 1 }) + .mockRejectedValueOnce(new Error('temporary network failure')) + .mockResolvedValueOnce({ records: [{ ...task, status: 'SUCCEEDED', processedRows: 12 }], page: 1, size: 20, total: 1, pages: 1 }) + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + expect(document.body.textContent).toContain('自动刷新暂时失败') + expect(document.body.textContent).toContain('客户列表') + + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + expect(listPlatformExportTasks).toHaveBeenCalledTimes(3) + expect(document.body.textContent).not.toContain('自动刷新暂时失败') + }) + + it('updates an open task detail from the latest polling result', async () => { + vi.useFakeTimers() + vi.mocked(listPlatformExportTasks) + .mockResolvedValueOnce({ records: [{ ...task, status: 'RUNNING', processedRows: 3 }], page: 1, size: 20, total: 1, pages: 1 }) + .mockResolvedValueOnce({ records: [{ ...task, status: 'SUCCEEDED', processedRows: 12 }], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(getPlatformExportTask).mockResolvedValue({ ...task, status: 'RUNNING', processedRows: 3 }) + mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + ;[...document.querySelectorAll('button')] + .find((item) => item.textContent?.trim() === '详情')?.click() + await flushPromises() + expect(document.body.textContent).toContain('3 / 12 行') + + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + expect(document.body.textContent).toContain('12 / 12 行') + }) }) diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index 886a70c4..796c5610 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -1,16 +1,18 @@ @@ -188,11 +245,15 @@ onBeforeUnmount(() => {

下载任务

共 {{ result.total }} 个任务
刷新 +
+ 自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }} + +
@@ -204,6 +265,47 @@ onBeforeUnmount(() => {
+ + + +
+
+
+

{{ detailTask.taskName }}

+

{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}

+
+ {{ statusMeta(detailTask.status).label }} +
+
+
任务编号
{{ detailTask.taskNo }}
+
申请人
{{ detailTask.requestedUsername || detailTask.requestedBy }}
+
申请时间
{{ formatDate(detailTask.requestedAt) }}
+
开始时间
{{ formatDate(detailTask.startedAt) }}
+
完成时间
{{ formatDate(detailTask.completedAt) }}
+
过期时间
{{ formatDate(detailTask.expiresAt) }}
+
处理数据
{{ detailTask.processedRows }} 行
+
文件大小
{{ formatSize(detailTask.sizeBytes) || '—' }}
+
文件名称
{{ detailTask.fileName || '—' }}
+
+
+ 失败原因 +

{{ detailTask.failureMessage }}

+
+
+
@@ -216,14 +318,38 @@ onBeforeUnmount(() => { .download-table-header { min-height: 64px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); } .download-table-header h2 { margin: 0; color: var(--cc-text); font-size: 16px; } .download-table-header span { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 12px; } +.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: 12px; } .table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; } .table-actions :deep(.el-button + .el-button) { margin-left: 0; } -.action-placeholder { color: var(--cc-muted); } .failure-cell { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .failure-message { color: var(--cc-danger); } +.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; } +.detail-load-error strong { color: var(--cc-danger); font-size: 15px; } +.detail-load-error p { margin: 6px 0; color: var(--cc-text); font-size: 13px; } +.detail-load-error :deep(.el-button) { margin-top: 16px; } +.task-detail { min-width: 0; } +.task-detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); } +.task-detail-summary h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; } +.task-detail-summary p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; } +.task-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.task-detail-grid > div { min-width: 0; padding: 16px 14px; border-bottom: 1px solid var(--cc-border); } +.task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 1px solid var(--cc-border); } +.task-detail-grid .is-wide { grid-column: 1 / -1; } +.task-detail-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 12px; } +.task-detail-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; } +.task-failure { margin-top: 18px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; background: color-mix(in srgb, var(--cc-danger) 5%, #fff); } +.task-failure strong { color: var(--cc-danger); font-size: 13px; } +.task-failure p { margin: 6px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.6; } small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; } @media (max-width: 720px) { - .download-error, .download-table-header { align-items: stretch; flex-direction: column; } - .download-table-header :deep(.el-button) { min-height: 40px; } + .download-error, .download-table-header, .download-sync-warning { align-items: stretch; flex-direction: column; } + .download-table-header :deep(.el-button), .table-actions :deep(.el-button) { min-height: 44px; } + .table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; } +} +@media (max-width: 480px) { + .task-detail-summary { flex-direction: column; } + .task-detail-grid { grid-template-columns: 1fr; } + .task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 0; } + .task-detail-grid .is-wide { grid-column: 1; } } diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts index c5c11713..d64cb9f6 100644 --- a/src/modules/iam/api/platformIam.test.ts +++ b/src/modules/iam/api/platformIam.test.ts @@ -266,7 +266,7 @@ describe('platform IAM roles', () => { it('creates, updates, and changes customer status with the platform tenant contract', async () => { const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE' } const create = { - tenantName: '柏悦物流', legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12', + tenantName: '柏悦物流', legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '标准版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000, registeredCapitalCurrency: 'CNY', diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index b720841b..dbc75643 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -30,6 +30,7 @@ export interface PlatformTenantResource { tenantName: string status: PlatformTenantStatus legalName: string | null + legalRepresentative: string | null unifiedSocialCreditCode: string | null contactName: string | null contactPhone: string | null @@ -51,6 +52,7 @@ export interface PlatformTenantResource { export interface CreatePlatformTenantRequest { tenantName: string legalName: string + legalRepresentative: string unifiedSocialCreditCode: string contactName: string contactPhone: string diff --git a/src/modules/iam/components/PlatformTenantSelector.vue b/src/modules/iam/components/PlatformTenantSelector.vue new file mode 100644 index 00000000..dea49042 --- /dev/null +++ b/src/modules/iam/components/PlatformTenantSelector.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/modules/iam/index.ts b/src/modules/iam/index.ts index 194097a7..40e9d230 100644 --- a/src/modules/iam/index.ts +++ b/src/modules/iam/index.ts @@ -1 +1,2 @@ export * from './api/platformIam' +export { default as PlatformTenantSelector } from './components/PlatformTenantSelector.vue' diff --git a/src/modules/master-data/counterparties/api/platformPartners.ts b/src/modules/master-data/counterparties/api/platformPartners.ts index 3c49bf06..4a0edc68 100644 --- a/src/modules/master-data/counterparties/api/platformPartners.ts +++ b/src/modules/master-data/counterparties/api/platformPartners.ts @@ -96,7 +96,7 @@ function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerRes } } -export async function listPlatformPartners(tenantId: number, query: PlatformPartnerQuery) { +export async function listPlatformPartners(tenantId: number | string, query: PlatformPartnerQuery) { const response = await http.get, ApiEnvelope>(basePath, { params: { tenantId, ...query }, }) @@ -111,7 +111,7 @@ export async function listPlatformPartners(tenantId: number, query: PlatformPart } } -export async function getPlatformPartner(tenantId: number, partnerId: number | string) { +export async function getPlatformPartner(tenantId: number | string, partnerId: number | string) { const response = await http.get, ApiEnvelope>( `${basePath}/${partnerId}`, { params: { tenantId } }, @@ -123,7 +123,7 @@ export async function downloadPlatformPartnerImportTemplate() { return http.get(`${basePath}/import-template`, { responseType: 'blob' }) } -export async function importPlatformPartners(tenantId: number, file: File) { +export async function importPlatformPartners(tenantId: number | string, file: File) { const form = new FormData() form.append('file', file) const response = await http.post, ApiEnvelope>( @@ -134,7 +134,7 @@ export async function importPlatformPartners(tenantId: number, file: File) { return unwrapApiResponse(response) } -export async function createPlatformPartner(tenantId: number, payload: SavePlatformPartnerRequest) { +export async function createPlatformPartner(tenantId: number | string, payload: SavePlatformPartnerRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { params: { tenantId }, }) @@ -142,7 +142,7 @@ export async function createPlatformPartner(tenantId: number, payload: SavePlatf } export async function updatePlatformPartner( - tenantId: number, + tenantId: number | string, partnerId: number | string, payload: SavePlatformPartnerRequest, ) { @@ -153,7 +153,7 @@ export async function updatePlatformPartner( } export async function changePlatformPartnerStatus( - tenantId: number, + tenantId: number | string, partnerId: number | string, status: PlatformPartnerStatus, ) { diff --git a/src/modules/master-data/counterparties/pages/PartnersView.test.ts b/src/modules/master-data/counterparties/pages/PartnersView.test.ts index 37dc4ce2..f3b0a00e 100644 --- a/src/modules/master-data/counterparties/pages/PartnersView.test.ts +++ b/src/modules/master-data/counterparties/pages/PartnersView.test.ts @@ -8,6 +8,8 @@ import { } from '@/modules/master-data/counterparties/api/platformPartners' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam' +import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue' import PartnersView from './PartnersView.vue' vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({ @@ -20,6 +22,8 @@ vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({ changePlatformPartnerStatus: vi.fn(), })) +vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() })) + class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} @@ -60,6 +64,16 @@ const partner: PlatformPartnerResource = { updatedAt: '2026-07-30T22:45:00', } +const tenant = (id: number | string, tenantName: string): PlatformTenantResource => ({ + id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName, + legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, + contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, + salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, + registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, + internalRemark: null, createdAt: null, approvedAt: null, +}) +const snowflakeTenantId = '9223372036854775806' + async function mountView() { vi.stubGlobal('localStorage', createMemoryStorage()) vi.stubGlobal('sessionStorage', createMemoryStorage()) @@ -89,6 +103,9 @@ describe('PartnersView real API integration', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' + vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ + records: [tenant(10001, '默认企业'), tenant(snowflakeTenantId, '雪花企业')], page: 1, size: 200, total: 2, pages: 1, + }) vi.mocked(listPlatformPartners).mockReset().mockResolvedValue({ records: [partner], page: 1, size: 20, total: 1, pages: 1, }) @@ -111,6 +128,17 @@ describe('PartnersView real API integration', () => { expect(wrapper.find('.table-panel').exists()).toBe(true) }) + it('preserves a snowflake tenant id as a string when switching enterprises', async () => { + const wrapper = await mountView() + + wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId) + await flushPromises() + + expect(listPlatformPartners).toHaveBeenLastCalledWith(snowflakeTenantId, { + page: 1, size: 20, keyword: undefined, type: undefined, status: undefined, + }) + }) + it('updates filters locally and only requests after search', async () => { const wrapper = await mountView() const keywordInput = wrapper.find('input[placeholder="单位名称、编码、联系人或电话"]') diff --git a/src/modules/master-data/counterparties/pages/PartnersView.vue b/src/modules/master-data/counterparties/pages/PartnersView.vue index fda8e05e..774e2b67 100644 --- a/src/modules/master-data/counterparties/pages/PartnersView.vue +++ b/src/modules/master-data/counterparties/pages/PartnersView.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/master-data/express-companies/api/platformExpressCompanies.ts b/src/modules/master-data/express-companies/api/platformExpressCompanies.ts index b369835f..7237997a 100644 --- a/src/modules/master-data/express-companies/api/platformExpressCompanies.ts +++ b/src/modules/master-data/express-companies/api/platformExpressCompanies.ts @@ -51,7 +51,7 @@ export interface PlatformExpressCompanyImportResult { const basePath = '/platform/v1/express-companies' export async function listPlatformExpressCompanies( - tenantId: number, + tenantId: number | string, page = 1, size = 20, keyword = '', @@ -76,7 +76,7 @@ export async function listPlatformExpressCompanies( } } -export async function getPlatformExpressCompany(tenantId: number, id: number | string) { +export async function getPlatformExpressCompany(tenantId: number | string, id: number | string) { const response = await http.get, ApiEnvelope>( `${basePath}/${id}`, { params: { tenantId } }, @@ -84,7 +84,7 @@ export async function getPlatformExpressCompany(tenantId: number, id: number | s return unwrapApiResponse(response) } -export async function createPlatformExpressCompany(tenantId: number, payload: SavePlatformExpressCompanyRequest) { +export async function createPlatformExpressCompany(tenantId: number | string, payload: SavePlatformExpressCompanyRequest) { const response = await http.post, ApiEnvelope>(basePath, payload, { params: { tenantId }, }) @@ -92,7 +92,7 @@ export async function createPlatformExpressCompany(tenantId: number, payload: Sa } export async function updatePlatformExpressCompany( - tenantId: number, + tenantId: number | string, id: number | string, payload: SavePlatformExpressCompanyRequest, ) { @@ -103,7 +103,7 @@ export async function updatePlatformExpressCompany( } export async function changePlatformExpressCompanyStatus( - tenantId: number, + tenantId: number | string, id: number | string, status: PlatformExpressCompanyStatus, ) { @@ -117,7 +117,7 @@ export async function downloadPlatformExpressCompanyImportTemplate() { return http.get(`${basePath}/import-template`, { responseType: 'blob' }) } -export async function importPlatformExpressCompanies(tenantId: number, file: File) { +export async function importPlatformExpressCompanies(tenantId: number | string, file: File) { const formData = new FormData() formData.append('file', file) const response = await http.post, ApiEnvelope>( diff --git a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.test.ts b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.test.ts index 0b23b4b2..09172bdc 100644 --- a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.test.ts +++ b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.test.ts @@ -5,6 +5,8 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites import { listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/modules/master-data/express-companies/api/platformExpressCompanies' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam' +import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue' import ExpressCompaniesView from './ExpressCompaniesView.vue' vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', () => ({ @@ -14,6 +16,8 @@ vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', changePlatformExpressCompanyStatus: vi.fn(), })) +vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() })) + class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} @@ -45,6 +49,16 @@ const company: PlatformExpressCompanyResource = { updatedAt: '2026-07-31T10:20:00', } +const tenant = (id: number | string, tenantName: string): PlatformTenantResource => ({ + id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName, + legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, + contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, + salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, + registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, + internalRemark: null, createdAt: null, approvedAt: null, +}) +const snowflakeTenantId = '9223372036854775806' + async function mountView() { vi.stubGlobal('localStorage', createMemoryStorage()) vi.stubGlobal('sessionStorage', createMemoryStorage()) @@ -69,6 +83,9 @@ describe('ExpressCompaniesView query and table migration', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' + vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ + records: [tenant(10001, '默认企业'), tenant(snowflakeTenantId, '雪花企业')], page: 1, size: 200, total: 2, pages: 1, + }) vi.mocked(listPlatformExpressCompanies).mockReset().mockResolvedValue({ records: [company], page: 1, size: 20, total: 1, pages: 1, }) @@ -95,4 +112,13 @@ describe('ExpressCompaniesView query and table migration', () => { expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(10001, 1, 20, '顺丰', '') }) + + it('preserves a snowflake tenant id as a string when switching enterprises', async () => { + const wrapper = await mountView() + + wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId) + await flushPromises() + + expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(snowflakeTenantId, 1, 20, '', '') + }) }) diff --git a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue index f3758786..01776c36 100644 --- a/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue +++ b/src/modules/master-data/express-companies/pages/ExpressCompaniesView.vue @@ -1,5 +1,5 @@