Procházet zdrojové kódy

fix: isolate team background dialog requests

Developer před 3 dny
rodič
revize
d17f293a62

+ 118 - 15
src/components/team-background/TeamBackgroundFileDialog.vue

@@ -106,6 +106,9 @@ export default {
       session: null,
       loading: false,
       sessionLoading: false,
+      contextGeneration: 0,
+      listRequestId: 0,
+      sessionRequestId: 0,
       sourceLabels: {
         WECHAT: '微信端',
         PC: '电脑端'
@@ -122,16 +125,60 @@ export default {
       }
     }
   },
+  watch: {
+    visible(value) {
+      if (!value) {
+        this.invalidateContext()
+        this.session = null
+      }
+    },
+    teamId(value, oldValue) {
+      if (value === oldValue) return
+      this.startContext()
+      if (this.visible) return this.refreshFiles()
+    }
+  },
   methods: {
     formatFileSize,
     handleOpen() {
-      this.queue = []
-      this.session = null
+      this.startContext()
       return this.refreshFiles()
     },
     handleClosed() {
+      this.invalidateContext()
+      this.session = null
       if (this.$refs.fileInput) this.$refs.fileInput.value = ''
     },
+    startContext() {
+      this.invalidateContext()
+      this.queue = []
+      this.fileList = []
+      this.session = null
+    },
+    invalidateContext() {
+      this.contextGeneration += 1
+      this.listRequestId += 1
+      this.sessionRequestId += 1
+      this.loading = false
+      this.sessionLoading = false
+    },
+    contextSnapshot() {
+      return {
+        generation: this.contextGeneration,
+        teamId: this.teamId
+      }
+    },
+    isContextCurrent(context) {
+      return this.visible &&
+        context.generation === this.contextGeneration &&
+        context.teamId === this.teamId
+    },
+    isListRequestCurrent(context, requestId) {
+      return this.isContextCurrent(context) && requestId === this.listRequestId
+    },
+    isSessionRequestCurrent(context, requestId) {
+      return this.isContextCurrent(context) && requestId === this.sessionRequestId
+    },
     createQueueItems(files) {
       return Array.from(files).map((file, index) => ({
         id: `${Date.now()}-${index}`,
@@ -164,15 +211,18 @@ export default {
       item.status = 'uploading'
       item.progress = 0
       item.error = ''
+      const context = this.contextSnapshot()
       try {
-        const response = await uploadTeamBackgroundFile(this.teamId, item.file)
+        const response = await uploadTeamBackgroundFile(context.teamId, item.file)
+        if (!this.isContextCurrent(context)) return
         this.requireSuccess(response, '上传失败,请重试')
         item.status = 'success'
         item.progress = 100
         await this.refreshFiles()
       } catch (reason) {
+        if (!this.isContextCurrent(context)) return
         item.status = 'failed'
-        item.error = reason.msg || reason.message || '上传失败,请重试'
+        item.error = this.errorMessage(reason, '上传失败,请重试')
       }
     },
     retryUpload(item) {
@@ -183,50 +233,100 @@ export default {
       if (index >= 0) this.queue.splice(index, 1)
     },
     async refreshFiles() {
-      if (this.teamId === '' || this.teamId === null || this.teamId === undefined) return
+      const context = this.contextSnapshot()
+      if (context.teamId === '' || context.teamId === null || context.teamId === undefined) return
+      const requestId = ++this.listRequestId
       this.loading = true
       try {
-        const response = await listTeamBackgroundFiles(this.teamId)
+        const response = await listTeamBackgroundFiles(context.teamId)
+        if (!this.isListRequestCurrent(context, requestId)) return
         const data = this.requireSuccess(response, '背景资料加载失败')
         this.fileList = Array.isArray(data) ? data : []
         this.$emit('changed', this.fileList.length)
       } catch (reason) {
-        this.$message.error(reason.msg || reason.message || '背景资料加载失败')
+        if (this.isListRequestCurrent(context, requestId)) {
+          this.$message.error(this.errorMessage(reason, '背景资料加载失败'))
+        }
       } finally {
-        this.loading = false
+        if (this.isListRequestCurrent(context, requestId)) this.loading = false
       }
     },
     async downloadFile(row) {
+      const context = this.contextSnapshot()
       try {
-        const blob = await downloadTeamBackgroundFile(this.teamId, row.id)
+        const blob = await downloadTeamBackgroundFile(context.teamId, row.id)
+        if (!this.isContextCurrent(context)) return
+        await this.rejectJsonDownload(blob)
+        if (!this.isContextCurrent(context)) return
         saveAs(blob, row.fileName)
       } catch (reason) {
-        this.$message.error(reason.msg || reason.message || '下载失败,请重试')
+        if (this.isContextCurrent(context)) {
+          this.$message.error(this.errorMessage(reason, '下载失败,请重试'))
+        }
       }
     },
     async disableFile(row) {
+      const context = this.contextSnapshot()
       try {
         await this.$confirm(`确认停用文件“${row.fileName}”吗?`, '提示', { type: 'warning' })
-        const response = await disableTeamBackgroundFile(this.teamId, row.id)
+        if (!this.isContextCurrent(context)) return
+        const response = await disableTeamBackgroundFile(context.teamId, row.id)
+        if (!this.isContextCurrent(context)) return
         this.requireSuccess(response, '停用失败,请重试')
         await this.refreshFiles()
+        if (!this.isContextCurrent(context)) return
         this.$message.success('文件已停用')
       } catch (reason) {
         if (reason === 'cancel' || reason === 'close') return
-        this.$message.error(reason.msg || reason.message || '停用失败,请重试')
+        if (this.isContextCurrent(context)) {
+          this.$message.error(this.errorMessage(reason, '停用失败,请重试'))
+        }
       }
     },
     async createUploadSession() {
+      const context = this.contextSnapshot()
+      if (context.teamId === '' || context.teamId === null || context.teamId === undefined) return
+      const requestId = ++this.sessionRequestId
       this.sessionLoading = true
       try {
-        const response = await createTeamBackgroundSession(this.teamId)
+        const response = await createTeamBackgroundSession(context.teamId)
+        if (!this.isSessionRequestCurrent(context, requestId)) return
         this.session = this.requireSuccess(response, '上传会话生成失败')
       } catch (reason) {
-        this.$message.error(reason.msg || reason.message || '上传会话生成失败')
+        if (this.isSessionRequestCurrent(context, requestId)) {
+          this.$message.error(this.errorMessage(reason, '上传会话生成失败'))
+        }
       } finally {
-        this.sessionLoading = false
+        if (this.isSessionRequestCurrent(context, requestId)) this.sessionLoading = false
       }
     },
+    async rejectJsonDownload(blob) {
+      const contentType = String((blob && blob.type) || '').toLowerCase()
+      if (!contentType.includes('application/json')) return
+      const text = await this.readBlobText(blob)
+      let payload = {}
+      try {
+        payload = JSON.parse(text)
+      } catch (_reason) {
+        // Keep the user-facing fallback for malformed JSON error bodies.
+      }
+      const error = new Error(payload.msg || payload.message || '下载失败,请重试')
+      error.msg = error.message
+      throw error
+    },
+    readBlobText(blob) {
+      if (blob && typeof blob.text === 'function') return blob.text()
+      return new Promise((resolve, reject) => {
+        if (typeof FileReader === 'undefined') {
+          reject(new Error('下载响应无法解析'))
+          return
+        }
+        const reader = new FileReader()
+        reader.onload = () => resolve(reader.result)
+        reader.onerror = () => reject(reader.error || new Error('下载响应无法解析'))
+        reader.readAsText(blob, 'UTF-8')
+      })
+    },
     async copyUploadUrl() {
       if (!this.session || !this.session.uploadUrl) return
       try {
@@ -254,6 +354,9 @@ export default {
         throw error
       }
       return response.data
+    },
+    errorMessage(reason, fallbackMessage) {
+      return (reason && (reason.msg || reason.message)) || fallbackMessage
     }
   }
 }

+ 1 - 1
src/views/modules/wechatUser/teamManager.vue

@@ -13,7 +13,7 @@
             </el-table-column>
             <el-table-column label="团队背景资料" min-width="120">
                 <template #default="{ row }">
-                    <el-link type="primary" @click="openBackgroundFiles(row)">{{ row.backgroundFileCount || 0 }} 个文档</el-link>
+                    <el-button type="text" size="mini" @click="openBackgroundFiles(row)">{{ row.backgroundFileCount || 0 }} 个文档</el-button>
                 </template>
             </el-table-column>
             <el-table-column label="所属地区" prop="">

+ 153 - 0
tests/unit/teamBackgroundManagement.spec.js

@@ -12,6 +12,16 @@ const managerPath = path.resolve(__dirname, '../../src/views/modules/wechatUser/
 
 const readIfPresent = file => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
 
+function deferred() {
+  let resolve
+  let reject
+  const promise = new Promise((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise
+    reject = rejectPromise
+  })
+  return { promise, resolve, reject }
+}
+
 function loadVueOptions(file, mocks = {}) {
   const source = readIfPresent(file)
   const descriptor = parseComponent(source)
@@ -87,9 +97,17 @@ describe('team background management dialog behavior', () => {
       ...options.methods,
       ...overrides
     }
+    vm.$componentWatch = options.watch || {}
     return vm
   }
 
+  function invokeWatcher(vm, name, value, oldValue) {
+    const watcher = vm.$componentWatch[name]
+    if (!watcher) return
+    const handler = typeof watcher === 'function' ? watcher : watcher.handler
+    return handler.call(vm, value, oldValue)
+  }
+
   beforeEach(() => {
     Object.values(api).forEach(mock => mock.mockReset())
     saveAs.mockReset()
@@ -188,6 +206,139 @@ describe('team background management dialog behavior', () => {
     }
   })
 
+  test('visible false and closed invalidate pending work and reset both loading flags immediately', () => {
+    if (!expectComponent(source)) return
+    api.listTeamBackgroundFiles.mockReturnValue(new Promise(() => {}))
+    api.createTeamBackgroundSession.mockReturnValue(new Promise(() => {}))
+    const vm = createVm()
+
+    vm.refreshFiles()
+    vm.createUploadSession()
+    expect(vm.loading).toBe(true)
+    expect(vm.sessionLoading).toBe(true)
+
+    vm.visible = false
+    invokeWatcher(vm, 'visible', false, true)
+    expect(vm.loading).toBe(false)
+    expect(vm.sessionLoading).toBe(false)
+
+    vm.loading = true
+    vm.sessionLoading = true
+    vm.handleClosed()
+    expect(vm.loading).toBe(false)
+    expect(vm.sessionLoading).toBe(false)
+  })
+
+  test('ignores late team-A list and session responses after close and reopen for team B', async () => {
+    if (!expectComponent(source)) return
+    const teamAList = deferred()
+    const teamBList = deferred()
+    const teamASession = deferred()
+    const teamBSession = deferred()
+    api.listTeamBackgroundFiles
+      .mockReturnValueOnce(teamAList.promise)
+      .mockReturnValueOnce(teamBList.promise)
+    api.createTeamBackgroundSession
+      .mockReturnValueOnce(teamASession.promise)
+      .mockReturnValueOnce(teamBSession.promise)
+    const vm = createVm({ teamId: 7 })
+
+    const listA = vm.handleOpen()
+    const sessionA = vm.createUploadSession()
+    vm.visible = false
+    invokeWatcher(vm, 'visible', false, true)
+    vm.teamId = 8
+    invokeWatcher(vm, 'teamId', 8, 7)
+    vm.visible = true
+    const listB = vm.handleOpen()
+    const sessionB = vm.createUploadSession()
+
+    const filesB = [{ id: 81 }, { id: 82 }]
+    const currentSession = { code: '888888', uploadUrl: 'https://upload.example/b', expiresAt: 'later' }
+    teamBList.resolve({ code: 0, data: filesB })
+    teamBSession.resolve({ code: 0, data: currentSession })
+    await Promise.all([listB, sessionB])
+
+    teamAList.resolve({ code: 0, data: [{ id: 71 }] })
+    teamASession.resolve({ code: 0, data: { code: '777777', uploadUrl: 'https://upload.example/a', expiresAt: 'old' } })
+    await Promise.all([listA, sessionA])
+
+    expect(api.listTeamBackgroundFiles.mock.calls).toEqual([[7], [8]])
+    expect(api.createTeamBackgroundSession.mock.calls).toEqual([[7], [8]])
+    expect(vm.fileList).toBe(filesB)
+    expect(vm.session).toBe(currentSession)
+    expect(vm.$emit.mock.calls.filter(call => call[0] === 'changed')).toEqual([['changed', 2]])
+    expect(vm.loading).toBe(false)
+    expect(vm.sessionLoading).toBe(false)
+  })
+
+  test('only the newest same-team list and session requests may update state', async () => {
+    if (!expectComponent(source)) return
+    const firstList = deferred()
+    const secondList = deferred()
+    const firstSession = deferred()
+    const secondSession = deferred()
+    api.listTeamBackgroundFiles
+      .mockReturnValueOnce(firstList.promise)
+      .mockReturnValueOnce(secondList.promise)
+    api.createTeamBackgroundSession
+      .mockReturnValueOnce(firstSession.promise)
+      .mockReturnValueOnce(secondSession.promise)
+    const vm = createVm()
+
+    const listOne = vm.refreshFiles()
+    const listTwo = vm.refreshFiles()
+    const sessionOne = vm.createUploadSession()
+    const sessionTwo = vm.createUploadSession()
+    const newestFiles = [{ id: 2 }, { id: 3 }]
+    const newestSession = { code: '222222', uploadUrl: 'https://upload.example/new', expiresAt: 'new' }
+    secondList.resolve({ code: 0, data: newestFiles })
+    secondSession.resolve({ code: 0, data: newestSession })
+    await Promise.all([listTwo, sessionTwo])
+
+    firstList.resolve({ code: 0, data: [{ id: 1 }] })
+    firstSession.resolve({ code: 0, data: { code: '111111', uploadUrl: 'https://upload.example/old', expiresAt: 'old' } })
+    await Promise.all([listOne, sessionOne])
+
+    expect(vm.fileList).toBe(newestFiles)
+    expect(vm.session).toBe(newestSession)
+    expect(vm.$emit.mock.calls.filter(call => call[0] === 'changed')).toEqual([['changed', 2]])
+    expect(vm.loading).toBe(false)
+    expect(vm.sessionLoading).toBe(false)
+  })
+
+  test('an old disable confirmation cannot act on a new team context', async () => {
+    if (!expectComponent(source)) return
+    const confirmation = deferred()
+    const vm = createVm({ teamId: 7, $confirm: jest.fn(() => confirmation.promise) })
+    const disabling = vm.disableFile({ id: 12, fileName: 'old.pdf' })
+
+    vm.visible = false
+    invokeWatcher(vm, 'visible', false, true)
+    vm.teamId = 8
+    invokeWatcher(vm, 'teamId', 8, 7)
+    confirmation.resolve()
+    await disabling
+
+    expect(api.disableTeamBackgroundFile).not.toHaveBeenCalled()
+  })
+
+  test('reports a JSON blob download error and never saves it as a file', async () => {
+    if (!expectComponent(source)) return
+    const blob = {
+      type: 'application/json;charset=UTF-8',
+      text: jest.fn().mockResolvedValue(JSON.stringify({ code: 500, msg: '文件已停用' }))
+    }
+    api.downloadTeamBackgroundFile.mockResolvedValue(blob)
+    const vm = createVm()
+
+    await vm.downloadFile({ id: 12, fileName: 'brief.pdf' })
+
+    expect(blob.text).toHaveBeenCalled()
+    expect(saveAs).not.toHaveBeenCalled()
+    expect(vm.$message.error).toHaveBeenCalledWith('文件已停用')
+  })
+
   test('shows complete file metadata and user-facing WECHAT/PC labels', () => {
     if (!expectComponent(source)) return
 
@@ -216,6 +367,8 @@ describe('team table integration contract', () => {
     expect(source).toContain("row.enterpriseWebsite || '无'")
     expect(source).toContain('row.backgroundFileCount || 0')
     expect(source).toContain('@click="openBackgroundFiles(row)"')
+    expect(source).toMatch(/<el-button[^>]+type="text"[^>]+@click="openBackgroundFiles\(row\)"/s)
+    expect(source).not.toMatch(/<el-link[^>]+@click="openBackgroundFiles\(row\)"/s)
   })
 
   test('mounts one dialog and updates only the matching row on changed(count)', () => {