Browse Source

feat: 支持PC端删除团队背景资料

Developer 3 weeks ago
parent
commit
2675862770

BIN
dist.zip


+ 2 - 0
src/api/teamBackgroundFile.js

@@ -14,6 +14,8 @@ export function uploadTeamBackgroundFile(teamId, file) {
 export const getPublicUploadSession = token => request({ url: '/public/team-background-upload/session', method: 'get', params: { token } })
 export const verifyPublicUploadCode = code => request({ url: '/public/team-background-upload/verify', method: 'post', data: { code } })
 export const completePublicUpload = token => request({ url: '/public/team-background-upload/complete', method: 'post', headers: { 'X-Upload-Token': token } })
+export const listPublicTeamBackgroundFiles = token => request({ url: '/public/team-background-upload/files', method: 'get', headers: { 'X-Upload-Token': token } })
+export const deletePublicTeamBackgroundFile = (token, fileId) => request({ url: `/public/team-background-upload/files/${fileId}`, method: 'delete', headers: { 'X-Upload-Token': token } })
 
 export function uploadPublicTeamBackgroundFile(token, file, onProgress) {
   const data = new FormData()

+ 5 - 5
src/components/team-background/TeamBackgroundFileDialog.vue

@@ -58,7 +58,7 @@
       <el-table-column label="操作" width="130" fixed="right">
         <template #default="{ row }">
           <el-button type="text" size="mini" @click="downloadFile(row)">下载</el-button>
-          <el-button type="text" size="mini" class="danger-action" @click="disableFile(row)">停用</el-button>
+          <el-button type="text" size="mini" class="danger-action" @click="disableFile(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -268,18 +268,18 @@ export default {
     async disableFile(row) {
       const context = this.contextSnapshot()
       try {
-        await this.$confirm(`确认停用文件“${row.fileName}”吗?`, '提示', { type: 'warning' })
+        await this.$confirm(`确认删除文件“${row.fileName}”吗?`, '提示', { type: 'warning' })
         if (!this.isContextCurrent(context)) return
         const response = await disableTeamBackgroundFile(context.teamId, row.id)
         if (!this.isContextCurrent(context)) return
-        this.requireSuccess(response, '停用失败,请重试')
+        this.requireSuccess(response, '删除失败,请重试')
         await this.refreshFiles()
         if (!this.isContextCurrent(context)) return
-        this.$message.success('文件已停用')
+        this.$message.success('文件已删除')
       } catch (reason) {
         if (reason === 'cancel' || reason === 'close') return
         if (this.isContextCurrent(context)) {
-          this.$message.error(this.errorMessage(reason, '停用失败,请重试'))
+          this.$message.error(this.errorMessage(reason, '删除失败,请重试'))
         }
       }
     },

+ 180 - 9
src/views/pages/team-background-upload.vue

@@ -70,6 +70,42 @@
         @remove="removeQueueItem"
       />
 
+      <div class="uploaded-files">
+        <div class="uploaded-files__header">
+          <h2>已上传资料({{ uploadedFiles.length }})</h2>
+          <el-button type="text" :loading="filesLoading" @click="refreshUploadedFiles">
+            刷新
+          </el-button>
+        </div>
+        <el-table
+          v-loading="filesLoading"
+          :data="uploadedFiles"
+          size="small"
+          border
+          empty-text="暂无已上传资料"
+        >
+          <el-table-column label="文件名" prop="fileName" min-width="220" show-overflow-tooltip />
+          <el-table-column label="大小" width="110">
+            <template #default="{ row }">{{ formatFileSize(row.fileSize) }}</template>
+          </el-table-column>
+          <el-table-column label="来源" width="90">
+            <template #default="{ row }">{{ sourceLabels[row.source] || row.source || '未知' }}</template>
+          </el-table-column>
+          <el-table-column label="上传时间" prop="createDate" width="170" />
+          <el-table-column label="操作" width="80" fixed="right">
+            <template #default="{ row }">
+              <el-button
+                type="text"
+                size="mini"
+                class="danger-action"
+                :loading="isDeleting(row.id)"
+                @click="deleteUploadedFile(row)"
+              >删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
       <el-button
         type="primary"
         class="primary-action"
@@ -87,11 +123,13 @@
 import TeamBackgroundUploadQueue from '@/components/team-background/TeamBackgroundUploadQueue.vue'
 import {
   completePublicUpload,
+  deletePublicTeamBackgroundFile,
   getPublicUploadSession,
+  listPublicTeamBackgroundFiles,
   uploadPublicTeamBackgroundFile,
   verifyPublicUploadCode
 } from '@/api/teamBackgroundFile'
-import { ACCEPT, validateBackgroundFile } from '@/utils/teamBackgroundFile'
+import { ACCEPT, formatFileSize, validateBackgroundFile } from '@/utils/teamBackgroundFile'
 
 const BACKEND_EXPIRY_PATTERN = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
 const ISO_EXPIRY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/
@@ -145,12 +183,20 @@ export default {
       session: null,
       code: '',
       queue: [],
+      uploadedFiles: [],
+      filesLoading: false,
+      filesRequestId: 0,
+      deletingIds: [],
       remainingSeconds: 0,
       successCount: 0,
       completing: false,
       countdownTimer: null,
       asyncGeneration: 0,
-      verificationRequestId: 0
+      verificationRequestId: 0,
+      sourceLabels: {
+        WECHAT: '微信端',
+        PC: '电脑端'
+      }
     }
   },
   computed: {
@@ -160,7 +206,8 @@ export default {
       return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
     },
     uploadDisabled() {
-      return this.state !== 'ready' || this.completing || this.hasActiveUpload()
+      return this.state !== 'ready' || this.completing ||
+        this.hasActiveUpload() || this.hasActiveDelete()
     },
     completeDisabled() {
       return this.uploadDisabled
@@ -188,6 +235,10 @@ export default {
       this.clearCountdown()
       this.session = null
       this.queue = []
+      this.uploadedFiles = []
+      this.filesLoading = false
+      this.filesRequestId += 1
+      this.deletingIds = []
       this.successCount = 0
       this.code = ''
       this.remainingSeconds = 0
@@ -203,7 +254,7 @@ export default {
       try {
         const response = await getPublicUploadSession(token)
         if (!this.isVerificationCurrent(context)) return
-        this.activateSession(this.requireSuccess(response, '上传链接无效或已过期'))
+        await this.activateSession(this.requireSuccess(response, '上传链接无效或已过期'))
       } catch (_reason) {
         if (this.isVerificationCurrent(context)) this.expireSession()
       }
@@ -217,7 +268,7 @@ export default {
       try {
         const response = await verifyPublicUploadCode(this.code)
         if (!this.isVerificationCurrent(context)) return
-        this.activateSession(this.requireSuccess(response, '验证码无效或已过期'))
+        await this.activateSession(this.requireSuccess(response, '验证码无效或已过期'))
       } catch (reason) {
         if (!this.isVerificationCurrent(context)) return
         this.state = 'code'
@@ -241,10 +292,15 @@ export default {
       this.invalidateAsyncWork()
       this.session = session
       this.queue = []
+      this.uploadedFiles = []
+      this.filesLoading = false
+      this.filesRequestId += 1
+      this.deletingIds = []
       this.successCount = 0
       this.completing = false
       this.state = 'ready'
       this.startCountdown()
+      return this.refreshUploadedFiles()
     },
     startCountdown() {
       this.clearCountdown()
@@ -273,6 +329,9 @@ export default {
       this.clearCountdown()
       this.session = null
       this.completing = false
+      this.filesLoading = false
+      this.filesRequestId += 1
+      this.deletingIds = []
       this.state = 'expired'
     },
     createQueueItems(files) {
@@ -288,10 +347,96 @@ export default {
     hasActiveUpload() {
       return this.queue.some(item => item.status === 'uploading')
     },
+    hasActiveDelete() {
+      return this.deletingIds.length > 0
+    },
+    formatFileSize,
+    isDeleting(fileId) {
+      return this.deletingIds.some(id => String(id) === String(fileId))
+    },
+    async refreshUploadedFiles() {
+      if (this.state !== 'ready' || !this.session) return
+      const context = this.sessionContext()
+      const requestId = ++this.filesRequestId
+      this.filesLoading = true
+      try {
+        const response = await listPublicTeamBackgroundFiles(context.uploadToken)
+        if (!this.isFileListRequestCurrent(context, requestId)) return
+        const data = this.requireSuccess(response, '已上传资料加载失败')
+        this.uploadedFiles = Array.isArray(data) ? data : []
+      } catch (reason) {
+        if (!this.isFileListRequestCurrent(context, requestId)) return
+        if (this.isSessionFailure(reason)) {
+          this.expireSession()
+          return
+        }
+        this.$message.error(this.errorMessage(reason, '已上传资料加载失败'))
+      } finally {
+        if (this.isFileListRequestCurrent(context, requestId)) this.filesLoading = false
+      }
+    },
+    isFileListRequestCurrent(context, requestId) {
+      return this.isSessionContextCurrent(context) && requestId === this.filesRequestId
+    },
+    upsertUploadedFile(file) {
+      if (!file || file.id === undefined || file.id === null) return
+      this.filesRequestId += 1
+      this.filesLoading = false
+      const index = this.uploadedFiles.findIndex(
+        candidate => String(candidate.id) === String(file.id)
+      )
+      if (index >= 0) {
+        this.uploadedFiles.splice(index, 1, file)
+      } else {
+        this.uploadedFiles.unshift(file)
+      }
+    },
+    async deleteUploadedFile(file) {
+      if (this.state !== 'ready' || this.completing || !this.session ||
+        !file || file.id === undefined || file.id === null || this.isDeleting(file.id)) return
+      const context = this.sessionContext()
+      try {
+        await this.$confirm(`确认删除文件“${file.fileName || '未命名文件'}”吗?`, '提示', {
+          type: 'warning'
+        })
+      } catch (_reason) {
+        return
+      }
+      if (!this.isSessionContextCurrent(context)) return
+      this.deletingIds.push(file.id)
+      try {
+        const response = await deletePublicTeamBackgroundFile(context.uploadToken, file.id)
+        if (!this.isSessionContextCurrent(context)) return
+        this.requireSuccess(response, '删除失败,请重试')
+        this.filesRequestId += 1
+        this.filesLoading = false
+        this.uploadedFiles = this.uploadedFiles.filter(
+          candidate => String(candidate.id) !== String(file.id)
+        )
+        this.queue = this.queue.filter(
+          item => String(item.serverFileId) !== String(file.id)
+        )
+        this.$message.success('文件已删除')
+      } catch (reason) {
+        if (!this.isSessionContextCurrent(context)) return
+        if (this.isSessionFailure(reason)) {
+          this.expireSession()
+          return
+        }
+        this.$message.error(this.errorMessage(reason, '删除失败,请重试'))
+      } finally {
+        if (this.isSessionContextCurrent(context)) {
+          this.deletingIds = this.deletingIds.filter(
+            id => String(id) !== String(file.id)
+          )
+        }
+      }
+    },
     async handleFileChange(event) {
       const selectedFiles = Array.from((event.target && event.target.files) || [])
       if (event.target) event.target.value = ''
-      if (this.state !== 'ready' || this.completing || !selectedFiles.length) return
+      if (this.state !== 'ready' || this.completing || this.hasActiveDelete() ||
+        !selectedFiles.length) return
       if (this.hasActiveUpload()) {
         this.$message.error('文件正在上传,请稍后再试')
         return
@@ -334,7 +479,11 @@ export default {
       try {
         const response = await uploadPublicTeamBackgroundFile(context.uploadToken, item.file, onProgress)
         if (!this.isSessionContextCurrent(context)) return
-        this.requireSuccess(response, '上传失败,请重试')
+        const uploaded = this.requireSuccess(response, '上传失败,请重试')
+        if (uploaded && uploaded.id !== undefined && uploaded.id !== null) {
+          item.serverFileId = uploaded.id
+          this.upsertUploadedFile(uploaded)
+        }
         item.status = 'success'
         item.progress = 100
       } catch (reason) {
@@ -345,7 +494,8 @@ export default {
       }
     },
     retryUpload(item) {
-      if (this.state !== 'ready' || this.completing || this.hasActiveUpload()) return Promise.resolve()
+      if (this.state !== 'ready' || this.completing ||
+        this.hasActiveUpload() || this.hasActiveDelete()) return Promise.resolve()
       return this.uploadOne(item)
     },
     removeQueueItem(item) {
@@ -355,7 +505,8 @@ export default {
       if (index >= 0) this.queue.splice(index, 1)
     },
     async completeUpload() {
-      if (this.state !== 'ready' || this.completing || !this.session || this.hasActiveUpload()) return
+      if (this.state !== 'ready' || this.completing || !this.session ||
+        this.hasActiveUpload() || this.hasActiveDelete()) return
       const context = this.sessionContext()
       this.completing = true
       try {
@@ -473,6 +624,26 @@ export default {
   align-items: center;
 }
 
+.uploaded-files {
+  margin-top: 24px;
+}
+
+.uploaded-files__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  h2 {
+    margin: 0;
+    color: #303133;
+    font-size: 16px;
+  }
+}
+
+.danger-action {
+  color: #f56c6c;
+}
+
 .file-input {
   display: none;
 }

+ 7 - 1
tests/unit/teamBackgroundFile.spec.js

@@ -8,9 +8,11 @@ import request from '@/utils/request2'
 import {
   completePublicUpload,
   createTeamBackgroundSession,
+  deletePublicTeamBackgroundFile,
   disableTeamBackgroundFile,
   downloadTeamBackgroundFile,
   getPublicUploadSession,
+  listPublicTeamBackgroundFiles,
   listTeamBackgroundFiles,
   uploadPublicTeamBackgroundFile,
   uploadTeamBackgroundFile,
@@ -120,13 +122,17 @@ test('uses fixed public routes and sends only server-issued credentials', () =>
 
   getPublicUploadSession('session-token')
   verifyPublicUploadCode('123456')
+  listPublicTeamBackgroundFiles('upload-token')
+  deletePublicTeamBackgroundFile('upload-token', 12)
   completePublicUpload('upload-token')
   uploadPublicTeamBackgroundFile('upload-token', file, onProgress)
 
-  const uploadData = request.mock.calls[3][0].data
+  const uploadData = request.mock.calls[5][0].data
   expect(request.mock.calls).toEqual([
     [{ url: '/public/team-background-upload/session', method: 'get', params: { token: 'session-token' } }],
     [{ url: '/public/team-background-upload/verify', method: 'post', data: { code: '123456' } }],
+    [{ url: '/public/team-background-upload/files', method: 'get', headers: { 'X-Upload-Token': 'upload-token' } }],
+    [{ url: '/public/team-background-upload/files/12', method: 'delete', headers: { 'X-Upload-Token': 'upload-token' } }],
     [{ url: '/public/team-background-upload/complete', method: 'post', headers: { 'X-Upload-Token': 'upload-token' } }],
     [{
       url: '/public/team-background-upload/files',

+ 2 - 1
tests/unit/teamBackgroundManagement.spec.js

@@ -174,7 +174,7 @@ describe('team background management dialog behavior', () => {
     expect(vm.$emit).toHaveBeenCalledWith('changed', 2)
   })
 
-  test('downloads only the authorized blob and disables through the team API', async () => {
+  test('downloads only the authorized blob and deletes through the team API', async () => {
     if (!expectComponent(source)) return
     const blob = { type: 'application/pdf' }
     api.downloadTeamBackgroundFile.mockResolvedValue(blob)
@@ -190,6 +190,7 @@ describe('team background management dialog behavior', () => {
     expect(saveAs).toHaveBeenCalledWith(blob, 'brief.pdf')
     expect(api.disableTeamBackgroundFile).toHaveBeenCalledWith(7, 12)
     expect(vm.refreshFiles).toHaveBeenCalledTimes(1)
+    expect(vm.$message.success).toHaveBeenCalledWith('文件已删除')
   })
 
   test('generates and displays a copyable, refreshable PC upload session', async () => {

+ 91 - 0
tests/unit/teamBackgroundPublicUpload.spec.js

@@ -119,6 +119,7 @@ function createPageVm(api, overrides = {}) {
     ...options.data(),
     $route: { query: {} },
     $message: { error: jest.fn(), success: jest.fn() },
+    $confirm: jest.fn().mockResolvedValue(),
     $refs: { fileInput: { value: 'selected', click: jest.fn() } },
     ...options.methods,
     ...overrides
@@ -167,7 +168,9 @@ describe('public team background upload page state machine', () => {
   beforeEach(() => {
     api = {
       completePublicUpload: jest.fn(),
+      deletePublicTeamBackgroundFile: jest.fn(),
       getPublicUploadSession: jest.fn(),
+      listPublicTeamBackgroundFiles: jest.fn().mockResolvedValue({ code: 0, data: [] }),
       uploadPublicTeamBackgroundFile: jest.fn(),
       verifyPublicUploadCode: jest.fn()
     }
@@ -548,6 +551,94 @@ describe('public team background upload page state machine', () => {
     expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
   })
 
+  test('loads the token-scoped uploaded list and deletes a selected file', async () => {
+    if (!expectPage(source)) return
+    const files = [
+      { id: 11, fileName: 'existing.pdf', fileSize: 10, source: 'PC' },
+      { id: 12, fileName: 'new.docx', fileSize: 20, source: 'WECHAT' }
+    ]
+    api.listPublicTeamBackgroundFiles.mockResolvedValue({ code: 0, data: files })
+    api.deletePublicTeamBackgroundFile.mockResolvedValue({ code: 0 })
+    const vm = createPageVm(api)
+
+    await vm.activateSession({
+      teamName: 'Alpha',
+      uploadToken: 'upload-token',
+      expiresAt: '2099-01-01 08:00:00'
+    })
+
+    expect(api.listPublicTeamBackgroundFiles).toHaveBeenCalledWith('upload-token')
+    expect(vm.uploadedFiles).toEqual(files)
+    vm.queue = [
+      { id: 'local', status: 'success', serverFileId: 12 },
+      { id: 'other', status: 'success', serverFileId: 99 }
+    ]
+
+    await vm.deleteUploadedFile(files[1])
+
+    expect(vm.$confirm).toHaveBeenCalledWith(
+      '确认删除文件“new.docx”吗?',
+      '提示',
+      { type: 'warning' }
+    )
+    expect(api.deletePublicTeamBackgroundFile).toHaveBeenCalledWith('upload-token', 12)
+    expect(vm.uploadedFiles).toEqual([files[0]])
+    expect(vm.queue).toEqual([{ id: 'other', status: 'success', serverFileId: 99 }])
+    expect(vm.$message.success).toHaveBeenCalledWith('文件已删除')
+    vm.$componentOptions.beforeDestroy.call(vm)
+  })
+
+  test('a successful upload is added to the deletable server file list', async () => {
+    if (!expectPage(source)) return
+    const uploaded = { id: 21, fileName: 'brief.pdf', fileSize: 100, source: 'PC' }
+    api.uploadPublicTeamBackgroundFile.mockResolvedValue({ code: 0, data: uploaded })
+    const vm = createPageVm(api, {
+      state: 'ready',
+      session: { uploadToken: 'upload-token' }
+    })
+    const item = {
+      id: 'local',
+      file: { name: 'brief.pdf', size: 100 },
+      name: 'brief.pdf',
+      progress: 0,
+      status: 'pending',
+      error: ''
+    }
+
+    await vm.uploadOne(item)
+
+    expect(item).toMatchObject({ status: 'success', progress: 100, serverFileId: 21 })
+    expect(vm.uploadedFiles).toEqual([uploaded])
+  })
+
+  test('a stale list response cannot hide a file uploaded while refresh was pending', async () => {
+    if (!expectPage(source)) return
+    const listRequest = deferred()
+    const uploaded = { id: 22, fileName: 'latest.pdf', fileSize: 100, source: 'PC' }
+    api.listPublicTeamBackgroundFiles.mockReturnValue(listRequest.promise)
+    api.uploadPublicTeamBackgroundFile.mockResolvedValue({ code: 0, data: uploaded })
+    const vm = createPageVm(api, {
+      state: 'ready',
+      session: { uploadToken: 'upload-token' }
+    })
+    const item = {
+      id: 'local',
+      file: { name: 'latest.pdf', size: 100 },
+      name: 'latest.pdf',
+      progress: 0,
+      status: 'pending',
+      error: ''
+    }
+
+    const refreshing = vm.refreshUploadedFiles()
+    await vm.uploadOne(item)
+    listRequest.resolve({ code: 0, data: [] })
+    await refreshing
+
+    expect(vm.uploadedFiles).toEqual([uploaded])
+    expect(vm.filesLoading).toBe(false)
+  })
+
   test('rejects overlapping batches, retry and completion until the active upload settles', async () => {
     if (!expectPage(source)) return
     const activeRequest = deferred()