Prechádzať zdrojové kódy

feat: manage team background files

Developer 3 dní pred
rodič
commit
70efd4946e

+ 307 - 0
src/components/team-background/TeamBackgroundFileDialog.vue

@@ -0,0 +1,307 @@
+<template>
+  <el-dialog
+    :visible.sync="dialogVisible"
+    :title="`${teamName || '团队'}背景资料`"
+    width="860px"
+    append-to-body
+    :close-on-click-modal="false"
+    @open="handleOpen"
+    @closed="handleClosed"
+  >
+    <div class="background-toolbar">
+      <input
+        ref="fileInput"
+        type="file"
+        multiple
+        :accept="ACCEPT"
+        class="background-file-input"
+        @change="handleFileChange"
+      >
+      <el-button type="primary" icon="el-icon-upload2" @click="$refs.fileInput.click()">
+        电脑上传
+      </el-button>
+      <el-button :loading="sessionLoading" @click="createUploadSession">
+        生成电脑上传码
+      </el-button>
+      <el-button icon="el-icon-refresh" :loading="loading" @click="refreshFiles">
+        刷新文件列表
+      </el-button>
+      <span class="background-toolbar__tip">一次最多选择 10 个文件,单个文件不超过 50MB</span>
+    </div>
+
+    <team-background-upload-queue
+      :files="queue"
+      @retry="retryUpload"
+      @remove="removeQueueItem"
+    />
+
+    <div v-if="session" class="upload-session">
+      <div><span class="upload-session__label">上传码</span><strong>{{ session.code }}</strong></div>
+      <div><span class="upload-session__label">有效期至</span>{{ session.expiresAt }}</div>
+      <div class="upload-session__url">
+        <span class="upload-session__label">上传地址</span>
+        <el-input :value="session.uploadUrl" readonly />
+        <el-button type="primary" plain @click="copyUploadUrl">复制地址</el-button>
+      </div>
+    </div>
+
+    <el-table v-loading="loading" :data="fileList" border empty-text="暂无背景资料">
+      <el-table-column label="文件名" prop="fileName" min-width="210" show-overflow-tooltip />
+      <el-table-column label="扩展名" prop="fileExt" width="90" />
+      <el-table-column label="大小" prop="fileSize" width="100">
+        <template #default="{ row }">{{ formatFileSize(row.fileSize) }}</template>
+      </el-table-column>
+      <el-table-column label="来源" prop="source" width="100">
+        <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="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>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">关 闭</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { saveAs } from 'file-saver'
+import TeamBackgroundUploadQueue from './TeamBackgroundUploadQueue.vue'
+import {
+  createTeamBackgroundSession,
+  disableTeamBackgroundFile,
+  downloadTeamBackgroundFile,
+  listTeamBackgroundFiles,
+  uploadTeamBackgroundFile
+} from '@/api/teamBackgroundFile'
+import { ACCEPT, formatFileSize, validateBackgroundFile } from '@/utils/teamBackgroundFile'
+
+export default {
+  name: 'TeamBackgroundFileDialog',
+  components: { TeamBackgroundUploadQueue },
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    teamId: {
+      type: [String, Number],
+      default: ''
+    },
+    teamName: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    return {
+      ACCEPT,
+      fileList: [],
+      queue: [],
+      session: null,
+      loading: false,
+      sessionLoading: false,
+      sourceLabels: {
+        WECHAT: '微信端',
+        PC: '电脑端'
+      }
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() {
+        return this.visible
+      },
+      set(value) {
+        this.$emit('update:visible', value)
+      }
+    }
+  },
+  methods: {
+    formatFileSize,
+    handleOpen() {
+      this.queue = []
+      this.session = null
+      return this.refreshFiles()
+    },
+    handleClosed() {
+      if (this.$refs.fileInput) this.$refs.fileInput.value = ''
+    },
+    createQueueItems(files) {
+      return Array.from(files).map((file, index) => ({
+        id: `${Date.now()}-${index}`,
+        file,
+        name: file.name,
+        progress: 0,
+        status: 'pending',
+        error: ''
+      }))
+    },
+    async handleFileChange(event) {
+      const selectedFiles = Array.from(event.target.files || [])
+      event.target.value = ''
+      if (!selectedFiles.length) return
+      if (selectedFiles.length > 10) {
+        this.$message.error('一次最多选择 10 个文件')
+        return
+      }
+      const items = this.createQueueItems(selectedFiles)
+      this.queue = this.queue.concat(items)
+      await Promise.all(items.map(item => this.uploadOne(item)))
+    },
+    async uploadOne(item) {
+      const error = validateBackgroundFile(item.file)
+      if (error) {
+        item.status = 'failed'
+        item.error = error
+        return
+      }
+      item.status = 'uploading'
+      item.progress = 0
+      item.error = ''
+      try {
+        const response = await uploadTeamBackgroundFile(this.teamId, item.file)
+        this.requireSuccess(response, '上传失败,请重试')
+        item.status = 'success'
+        item.progress = 100
+        await this.refreshFiles()
+      } catch (reason) {
+        item.status = 'failed'
+        item.error = reason.msg || reason.message || '上传失败,请重试'
+      }
+    },
+    retryUpload(item) {
+      return this.uploadOne(item)
+    },
+    removeQueueItem(item) {
+      const index = this.queue.findIndex(candidate => candidate.id === item.id)
+      if (index >= 0) this.queue.splice(index, 1)
+    },
+    async refreshFiles() {
+      if (this.teamId === '' || this.teamId === null || this.teamId === undefined) return
+      this.loading = true
+      try {
+        const response = await listTeamBackgroundFiles(this.teamId)
+        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 || '背景资料加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async downloadFile(row) {
+      try {
+        const blob = await downloadTeamBackgroundFile(this.teamId, row.id)
+        saveAs(blob, row.fileName)
+      } catch (reason) {
+        this.$message.error(reason.msg || reason.message || '下载失败,请重试')
+      }
+    },
+    async disableFile(row) {
+      try {
+        await this.$confirm(`确认停用文件“${row.fileName}”吗?`, '提示', { type: 'warning' })
+        const response = await disableTeamBackgroundFile(this.teamId, row.id)
+        this.requireSuccess(response, '停用失败,请重试')
+        await this.refreshFiles()
+        this.$message.success('文件已停用')
+      } catch (reason) {
+        if (reason === 'cancel' || reason === 'close') return
+        this.$message.error(reason.msg || reason.message || '停用失败,请重试')
+      }
+    },
+    async createUploadSession() {
+      this.sessionLoading = true
+      try {
+        const response = await createTeamBackgroundSession(this.teamId)
+        this.session = this.requireSuccess(response, '上传会话生成失败')
+      } catch (reason) {
+        this.$message.error(reason.msg || reason.message || '上传会话生成失败')
+      } finally {
+        this.sessionLoading = false
+      }
+    },
+    async copyUploadUrl() {
+      if (!this.session || !this.session.uploadUrl) return
+      try {
+        if (navigator.clipboard && navigator.clipboard.writeText) {
+          await navigator.clipboard.writeText(this.session.uploadUrl)
+        } else {
+          const input = document.createElement('textarea')
+          input.value = this.session.uploadUrl
+          input.style.position = 'fixed'
+          input.style.opacity = '0'
+          document.body.appendChild(input)
+          input.select()
+          document.execCommand('copy')
+          document.body.removeChild(input)
+        }
+        this.$message.success('上传地址已复制')
+      } catch (_reason) {
+        this.$message.error('复制失败,请手动复制')
+      }
+    },
+    requireSuccess(response, fallbackMessage) {
+      if (!response || response.code !== 0) {
+        const error = new Error((response && response.msg) || fallbackMessage)
+        error.msg = error.message
+        throw error
+      }
+      return response.data
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.background-toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.background-file-input {
+  display: none;
+}
+
+.background-toolbar__tip {
+  margin-left: 14px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.upload-session {
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  color: #606266;
+  line-height: 32px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+.upload-session__label {
+  display: inline-block;
+  width: 68px;
+  color: #909399;
+}
+
+.upload-session__url {
+  display: flex;
+  align-items: center;
+
+  .el-input {
+    flex: 1;
+    margin-right: 8px;
+  }
+}
+
+.danger-action {
+  color: #f56c6c;
+}
+</style>

+ 108 - 0
src/components/team-background/TeamBackgroundUploadQueue.vue

@@ -0,0 +1,108 @@
+<template>
+  <div v-if="files.length" class="team-background-upload-queue">
+    <div v-for="item in files" :key="item.id" class="queue-item">
+      <div class="queue-item__main">
+        <span class="queue-item__name" :title="item.name">{{ item.name }}</span>
+        <span :class="['queue-item__status', `is-${item.status}`]">
+          {{ statusLabels[item.status] || item.status }}
+        </span>
+      </div>
+      <el-progress
+        v-if="item.status === 'uploading'"
+        :percentage="item.progress"
+        :show-text="false"
+      />
+      <div v-if="item.error" class="queue-item__error">{{ item.error }}</div>
+      <div class="queue-item__actions">
+        <el-button
+          v-if="item.status === 'failed'"
+          type="text"
+          size="mini"
+          @click="$emit('retry', item)"
+        >重试</el-button>
+        <el-button
+          v-if="item.status !== 'uploading'"
+          type="text"
+          size="mini"
+          @click="$emit('remove', item)"
+        >移除</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'TeamBackgroundUploadQueue',
+  props: {
+    files: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      statusLabels: {
+        pending: '等待上传',
+        uploading: '上传中',
+        success: '上传成功',
+        failed: '上传失败'
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.team-background-upload-queue {
+  margin: 12px 0 18px;
+}
+
+.queue-item {
+  position: relative;
+  padding: 10px 104px 10px 12px;
+  margin-top: 8px;
+  background: #f7f8fa;
+  border-radius: 4px;
+}
+
+.queue-item__main {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+}
+
+.queue-item__name {
+  overflow: hidden;
+  color: #303133;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.queue-item__status {
+  flex: none;
+  margin-left: 12px;
+  color: #909399;
+
+  &.is-success {
+    color: #67c23a;
+  }
+
+  &.is-failed {
+    color: #f56c6c;
+  }
+}
+
+.queue-item__error {
+  margin-top: 6px;
+  color: #f56c6c;
+  font-size: 12px;
+}
+
+.queue-item__actions {
+  position: absolute;
+  top: 4px;
+  right: 12px;
+  white-space: nowrap;
+}
+</style>

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

@@ -8,6 +8,14 @@
             </el-table-column>
             <el-table-column label="团队名称" prop="teamName"></el-table-column>
             <el-table-column label="所在公司" prop="enterpriseName"></el-table-column>
+            <el-table-column label="企业官网" prop="enterpriseWebsite" min-width="180">
+                <template #default="{ row }">{{ row.enterpriseWebsite || '无' }}</template>
+            </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>
+                </template>
+            </el-table-column>
             <el-table-column label="所属地区" prop="">
                 <template #default="{ row }">{{ row.provinceName+row.cityName }}</template>
             </el-table-column>
@@ -35,6 +43,12 @@
                 v-show="total > 0">
             </el-pagination>
         </el-row>
+        <team-background-file-dialog
+            :visible.sync="backgroundDialogVisible"
+            :team-id="activeTeam ? activeTeam.id : ''"
+            :team-name="activeTeam ? activeTeam.teamName : ''"
+            @changed="handleBackgroundFilesChanged"
+        />
     </div>
 </template>
 
@@ -47,6 +61,7 @@
         }
     });
     import { ref, getCurrentInstance, onMounted } from 'vue'
+    import TeamBackgroundFileDialog from '@/components/team-background/TeamBackgroundFileDialog.vue'
     const { proxy } = getCurrentInstance();
     const { team_scale, team_hierarchy } = proxy.useDict("team_scale","team_hierarchy");
     import { 
@@ -61,6 +76,18 @@
     const dataList = ref([])
     const total = ref(0)
     const loading = ref(false)
+    const activeTeam = ref(null)
+    const backgroundDialogVisible = ref(false)
+
+    const openBackgroundFiles = (row) => {
+        activeTeam.value = row;
+        backgroundDialogVisible.value = true;
+    }
+
+    const handleBackgroundFilesChanged = (count) => {
+        const row = dataList.value.find(team => activeTeam.value && team.id === activeTeam.value.id);
+        if (row) row.backgroundFileCount = count;
+    }
 
     const handleSizeChange = (val) => {
         queryParams.value.limit = val;
@@ -92,4 +119,4 @@
 
 <style scoped lang="scss">
     
-</style>
+</style>

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

@@ -0,0 +1,229 @@
+import fs from 'fs'
+import path from 'path'
+import * as babel from '@babel/core'
+import { parseComponent } from 'vue-template-compiler'
+import { ACCEPT, formatFileSize, validateBackgroundFile } from '@/utils/teamBackgroundFile'
+
+process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'
+
+const queuePath = path.resolve(__dirname, '../../src/components/team-background/TeamBackgroundUploadQueue.vue')
+const dialogPath = path.resolve(__dirname, '../../src/components/team-background/TeamBackgroundFileDialog.vue')
+const managerPath = path.resolve(__dirname, '../../src/views/modules/wechatUser/teamManager.vue')
+
+const readIfPresent = file => fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
+
+function loadVueOptions(file, mocks = {}) {
+  const source = readIfPresent(file)
+  const descriptor = parseComponent(source)
+  const code = babel.transformSync(descriptor.script.content, {
+    babelrc: false,
+    configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+  const module = { exports: {} }
+  const localRequire = id => {
+    if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id]
+    throw new Error(`Missing test mock for ${id}`)
+  }
+  const evaluate = new Function('require', 'module', 'exports', code)
+  evaluate(localRequire, module, module.exports)
+  return module.exports.default
+}
+
+function expectComponent(source) {
+  expect(source).not.toBe('')
+  return source !== ''
+}
+
+describe('team background upload queue contract', () => {
+  const source = readIfPresent(queuePath)
+
+  test('accepts queue items and renders every required status without mutating the list', () => {
+    if (!expectComponent(source)) return
+    const options = loadVueOptions(queuePath)
+
+    expect(options.props.files).toBeDefined()
+    for (const state of ['pending', 'uploading', 'success', 'failed']) {
+      expect(source).toContain(state)
+    }
+    expect(source).not.toMatch(/files\.(?:splice|pop|shift)|files\s*=|filter\([^)]*status/)
+  })
+
+  test('exposes explicit retry and remove actions', () => {
+    if (!expectComponent(source)) return
+
+    expect(source).toMatch(/\$emit\(['"]retry['"],\s*item\)/)
+    expect(source).toMatch(/\$emit\(['"]remove['"],\s*item\)/)
+  })
+})
+
+describe('team background management dialog behavior', () => {
+  const source = readIfPresent(dialogPath)
+  const api = {
+    createTeamBackgroundSession: jest.fn(),
+    disableTeamBackgroundFile: jest.fn(),
+    downloadTeamBackgroundFile: jest.fn(),
+    listTeamBackgroundFiles: jest.fn(),
+    uploadTeamBackgroundFile: jest.fn()
+  }
+  const saveAs = jest.fn()
+
+  function createVm(overrides = {}) {
+    const options = loadVueOptions(dialogPath, {
+      '@/api/teamBackgroundFile': api,
+      '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
+      'file-saver': { saveAs },
+      './TeamBackgroundUploadQueue.vue': {}
+    })
+    const vm = {
+      ...options.data(),
+      visible: true,
+      teamId: 7,
+      teamName: 'Alpha',
+      $emit: jest.fn(),
+      $message: { success: jest.fn(), error: jest.fn() },
+      $confirm: jest.fn().mockResolvedValue(),
+      $refs: { fileInput: { value: 'selected' } },
+      ...options.methods,
+      ...overrides
+    }
+    return vm
+  }
+
+  beforeEach(() => {
+    Object.values(api).forEach(mock => mock.mockReset())
+    saveAs.mockReset()
+  })
+
+  test('creates the exact queue item shape and rejects more than ten files before enqueueing', async () => {
+    if (!expectComponent(source)) return
+    const vm = createVm()
+    const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.docx', size: 2 }]
+    const now = jest.spyOn(Date, 'now').mockReturnValue(123456)
+
+    expect(vm.createQueueItems(files)).toEqual([
+      { id: '123456-0', file: files[0], name: 'one.pdf', progress: 0, status: 'pending', error: '' },
+      { id: '123456-1', file: files[1], name: 'two.docx', progress: 0, status: 'pending', error: '' }
+    ])
+    now.mockRestore()
+
+    vm.uploadOne = jest.fn()
+    await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
+    expect(vm.queue).toEqual([])
+    expect(vm.uploadOne).not.toHaveBeenCalled()
+    expect(vm.$message.error).toHaveBeenCalledWith(expect.stringContaining('10'))
+  })
+
+  test('validates before upload and retains a successful item when another upload fails', async () => {
+    if (!expectComponent(source)) return
+    const vm = createVm()
+    const success = { id: '1-0', file: { name: 'ok.pdf', size: 1 }, name: 'ok.pdf', progress: 0, status: 'pending', error: '' }
+    const failure = { id: '1-1', file: { name: 'bad.docx', size: 1 }, name: 'bad.docx', progress: 0, status: 'pending', error: '' }
+    const invalid = { id: '1-2', file: { name: 'bad.zip', size: 1 }, name: 'bad.zip', progress: 0, status: 'pending', error: '' }
+    vm.queue = [success, failure, invalid]
+    vm.refreshFiles = jest.fn().mockResolvedValue()
+    api.uploadTeamBackgroundFile
+      .mockResolvedValueOnce({ code: 0, data: {} })
+      .mockRejectedValueOnce({ msg: '网络失败' })
+
+    await vm.uploadOne(success)
+    await vm.uploadOne(failure)
+    await vm.uploadOne(invalid)
+
+    expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
+    expect(failure).toMatchObject({ status: 'failed', error: '网络失败' })
+    expect(invalid).toMatchObject({ status: 'failed', error: expect.stringContaining('文件类型') })
+    expect(vm.queue).toEqual([success, failure, invalid])
+    expect(api.uploadTeamBackgroundFile).toHaveBeenCalledTimes(2)
+  })
+
+  test('uses a multiple accept-filtered input and keeps the ten-file batch cap in source', () => {
+    if (!expectComponent(source)) return
+
+    expect(source).toMatch(/<input[^>]+type="file"[^>]+multiple[^>]+:accept="ACCEPT"/s)
+    expect(source).toMatch(/selectedFiles\.length\s*>\s*10/)
+  })
+
+  test('refreshes active files and emits their count', async () => {
+    if (!expectComponent(source)) return
+    const rows = [{ id: 1 }, { id: 2 }]
+    api.listTeamBackgroundFiles.mockResolvedValue({ code: 0, data: rows })
+    const vm = createVm()
+
+    await vm.refreshFiles()
+
+    expect(vm.fileList).toBe(rows)
+    expect(vm.$emit).toHaveBeenCalledWith('changed', 2)
+  })
+
+  test('downloads only the authorized blob and disables through the team API', async () => {
+    if (!expectComponent(source)) return
+    const blob = { type: 'application/pdf' }
+    api.downloadTeamBackgroundFile.mockResolvedValue(blob)
+    api.disableTeamBackgroundFile.mockResolvedValue({ code: 0 })
+    const vm = createVm()
+    vm.refreshFiles = jest.fn().mockResolvedValue()
+    const row = { id: 12, fileName: 'brief.pdf', url: 'https://untrusted.example/file' }
+
+    await vm.downloadFile(row)
+    await vm.disableFile(row)
+
+    expect(api.downloadTeamBackgroundFile).toHaveBeenCalledWith(7, 12)
+    expect(saveAs).toHaveBeenCalledWith(blob, 'brief.pdf')
+    expect(api.disableTeamBackgroundFile).toHaveBeenCalledWith(7, 12)
+    expect(vm.refreshFiles).toHaveBeenCalledTimes(1)
+  })
+
+  test('generates and displays a copyable, refreshable PC upload session', async () => {
+    if (!expectComponent(source)) return
+    const session = { code: '123456', uploadUrl: 'https://upload.example/token', expiresAt: '2026-07-21 18:00:00' }
+    api.createTeamBackgroundSession.mockResolvedValue({ code: 0, data: session })
+    const vm = createVm()
+
+    await vm.createUploadSession()
+
+    expect(vm.session).toBe(session)
+    for (const field of ['session.code', 'session.expiresAt', 'session.uploadUrl', 'copyUploadUrl', 'refreshFiles']) {
+      expect(source).toContain(field)
+    }
+  })
+
+  test('shows complete file metadata and user-facing WECHAT/PC labels', () => {
+    if (!expectComponent(source)) return
+
+    for (const field of ['fileName', 'fileExt', 'fileSize', 'source', 'createDate']) {
+      expect(source).toContain(field)
+    }
+    expect(source).toContain("WECHAT: '微信端'")
+    expect(source).toContain("PC: '电脑端'")
+    expect(source).toContain('formatFileSize')
+  })
+})
+
+describe('team table integration contract', () => {
+  const source = readIfPresent(managerPath)
+
+  test('places enterprise website immediately after company and renders the document count link', () => {
+    expect(source).toContain('label="企业官网" prop="enterpriseWebsite"')
+    const company = source.indexOf('label="所在公司"')
+    const website = source.indexOf('label="企业官网"')
+    const region = source.indexOf('label="所属地区"')
+    const nextLabel = source.slice(company + 1).match(/label="([^"]+)"/)
+    expect(company).toBeGreaterThan(-1)
+    expect(website).toBeGreaterThan(company)
+    expect(region).toBeGreaterThan(website)
+    expect(nextLabel[1]).toBe('企业官网')
+    expect(source).toContain("row.enterpriseWebsite || '无'")
+    expect(source).toContain('row.backgroundFileCount || 0')
+    expect(source).toContain('@click="openBackgroundFiles(row)"')
+  })
+
+  test('mounts one dialog and updates only the matching row on changed(count)', () => {
+    const mounts = source.match(/<team-background-file-dialog\b/g) || []
+    expect(mounts).toHaveLength(1)
+    expect(source).toContain('@changed="handleBackgroundFilesChanged"')
+    expect(source).toMatch(/dataList\.value\.find\([^)]*activeTeam\.value\.id/)
+    expect(source).toMatch(/row\.backgroundFileCount\s*=\s*count/)
+    expect(source).not.toMatch(/handleBackgroundFilesChanged[\s\S]{0,180}getList\(/)
+  })
+})