Procházet zdrojové kódy

feat: add public team document uploader

Developer před 3 dny
rodič
revize
b7f583d2f1

+ 7 - 0
src/router/index.js

@@ -32,6 +32,12 @@ export const pageRoutes = [
     name: "login",
     meta: { title: "登录" },
   },
+  {
+    path: '/team-background-upload',
+    component: () => import('@/views/pages/team-background-upload'),
+    name: 'team-background-upload',
+    meta: { title: '上传团队背景资料', public: true }
+  },
   {
     path: "/infoWindow",
     component: () => import("@/views/modules/situational/components/infoWindow/index.vue"),
@@ -103,6 +109,7 @@ const router = new Router({
 });
 
 router.beforeEach((to, from, next) => {
+  if (to.matched.some(record => record.meta.public === true)) return next()
   if (to.path == "infoWindow") {
     next();
   } else {

+ 427 - 0
src/views/pages/team-background-upload.vue

@@ -0,0 +1,427 @@
+<template>
+  <main class="public-upload-page">
+    <section v-if="state === 'expired'" class="public-upload-card terminal-card">
+      请返回小程序或管理端重新生成上传链接
+    </section>
+
+    <section v-else-if="state === 'completed'" class="public-upload-card terminal-card">
+      <i class="el-icon-circle-check terminal-card__icon is-success" />
+      <h1>上传已完成</h1>
+      <p>成功上传 {{ successCount }} 个文件</p>
+    </section>
+
+    <section v-else-if="state === 'verifying'" class="public-upload-card terminal-card">
+      <i class="el-icon-loading terminal-card__icon" />
+      <p>正在验证上传链接</p>
+    </section>
+
+    <section v-else-if="state === 'code'" class="public-upload-card code-card">
+      <h1>上传团队背景资料</h1>
+      <p class="muted-text">请输入小程序或管理端生成的 6 位验证码</p>
+      <el-input
+        v-model.trim="code"
+        class="code-input"
+        maxlength="6"
+        inputmode="numeric"
+        autocomplete="one-time-code"
+        placeholder="6 位数字验证码"
+        @keyup.enter.native="verifyCode"
+      />
+      <el-button type="primary" class="primary-action" @click="verifyCode">
+        验证并继续
+      </el-button>
+    </section>
+
+    <section v-else-if="state === 'ready'" class="public-upload-card ready-card">
+      <header class="ready-card__header">
+        <h1>{{ session.teamName }}</h1>
+        <p>上传团队背景资料</p>
+      </header>
+
+      <div class="session-expiry">
+        <span>有效期至:{{ session.expiresAt }}</span>
+        <span>剩余时间:{{ countdownText }}</span>
+      </div>
+
+      <div class="upload-picker">
+        <input
+          ref="fileInput"
+          type="file"
+          multiple
+          :accept="ACCEPT"
+          :disabled="uploadDisabled"
+          class="file-input"
+          @change="handleFileChange"
+        >
+        <el-button
+          type="primary"
+          icon="el-icon-upload2"
+          :disabled="uploadDisabled"
+          @click="$refs.fileInput.click()"
+        >
+          选择文件
+        </el-button>
+        <span class="muted-text">一次最多选择 10 个文件,单个文件不超过 50MB</span>
+      </div>
+
+      <team-background-upload-queue
+        :files="queue"
+        @retry="retryUpload"
+        @remove="removeQueueItem"
+      />
+
+      <el-button
+        type="primary"
+        class="primary-action"
+        :loading="completing"
+        :disabled="completeDisabled"
+        @click="completeUpload"
+      >
+        完成上传
+      </el-button>
+    </section>
+  </main>
+</template>
+
+<script>
+import TeamBackgroundUploadQueue from '@/components/team-background/TeamBackgroundUploadQueue.vue'
+import {
+  completePublicUpload,
+  getPublicUploadSession,
+  uploadPublicTeamBackgroundFile,
+  verifyPublicUploadCode
+} from '@/api/teamBackgroundFile'
+import { ACCEPT, validateBackgroundFile } from '@/utils/teamBackgroundFile'
+
+export default {
+  name: 'TeamBackgroundUpload',
+  components: { TeamBackgroundUploadQueue },
+  data() {
+    return {
+      ACCEPT,
+      state: 'verifying',
+      session: null,
+      code: '',
+      queue: [],
+      remainingSeconds: 0,
+      successCount: 0,
+      completing: false,
+      countdownTimer: null,
+      asyncGeneration: 0,
+      verificationRequestId: 0
+    }
+  },
+  computed: {
+    countdownText() {
+      const minutes = Math.floor(this.remainingSeconds / 60)
+      const seconds = this.remainingSeconds % 60
+      return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
+    },
+    uploadDisabled() {
+      return this.state !== 'ready' || this.completing
+    },
+    completeDisabled() {
+      return this.uploadDisabled ||
+        this.queue.some(item => item.status === 'uploading')
+    }
+  },
+  created() {
+    return this.initialize()
+  },
+  beforeDestroy() {
+    this.invalidateAsyncWork()
+    this.clearCountdown()
+  },
+  methods: {
+    initialize() {
+      const token = this.$route && this.$route.query && this.$route.query.token
+      if (token) return this.loadFromToken(String(token))
+      this.state = 'code'
+      return Promise.resolve()
+    },
+    async loadFromToken(token) {
+      const context = this.startVerification()
+      try {
+        const response = await getPublicUploadSession(token)
+        if (!this.isVerificationCurrent(context)) return
+        this.activateSession(this.requireSuccess(response, '上传链接无效或已过期'))
+      } catch (_reason) {
+        if (this.isVerificationCurrent(context)) this.expireSession()
+      }
+    },
+    async verifyCode() {
+      if (!/^\d{6}$/.test(this.code)) {
+        this.$message.error('请输入6位数字验证码')
+        return
+      }
+      const context = this.startVerification()
+      try {
+        const response = await verifyPublicUploadCode(this.code)
+        if (!this.isVerificationCurrent(context)) return
+        this.activateSession(this.requireSuccess(response, '验证码无效或已过期'))
+      } catch (reason) {
+        if (!this.isVerificationCurrent(context)) return
+        this.state = 'code'
+        this.$message.error(this.errorMessage(reason, '验证码无效或已过期'))
+      }
+    },
+    startVerification() {
+      this.clearCountdown()
+      this.state = 'verifying'
+      return {
+        generation: this.asyncGeneration,
+        requestId: ++this.verificationRequestId
+      }
+    },
+    isVerificationCurrent(context) {
+      return this.state === 'verifying' &&
+        context.generation === this.asyncGeneration &&
+        context.requestId === this.verificationRequestId
+    },
+    activateSession(session) {
+      this.invalidateAsyncWork()
+      this.session = session
+      this.queue = []
+      this.successCount = 0
+      this.completing = false
+      this.state = 'ready'
+      this.startCountdown()
+    },
+    startCountdown() {
+      this.clearCountdown()
+      this.updateRemainingSeconds()
+      if (this.state === 'ready') {
+        this.countdownTimer = setInterval(() => this.updateRemainingSeconds(), 1000)
+      }
+    },
+    updateRemainingSeconds() {
+      if (this.state !== 'ready' || !this.session) return
+      const expiresAt = new Date(this.session.expiresAt).getTime()
+      this.remainingSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))
+      if (!Number.isFinite(expiresAt) || this.remainingSeconds === 0) this.expireSession()
+    },
+    clearCountdown() {
+      if (this.countdownTimer !== null) clearInterval(this.countdownTimer)
+      this.countdownTimer = null
+    },
+    invalidateAsyncWork() {
+      this.asyncGeneration += 1
+      this.verificationRequestId += 1
+    },
+    expireSession() {
+      if (this.state === 'completed') return
+      this.invalidateAsyncWork()
+      this.clearCountdown()
+      this.session = null
+      this.completing = false
+      this.state = 'expired'
+    },
+    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 && event.target.files) || [])
+      if (event.target) event.target.value = ''
+      if (this.state !== 'ready' || this.completing || !selectedFiles.length) return
+      if (selectedFiles.length > 10) {
+        this.$message.error('一次最多选择 10 个文件')
+        return
+      }
+      const items = this.createQueueItems(selectedFiles)
+      this.queue = this.queue.concat(items)
+      for (const item of items) {
+        if (this.state !== 'ready' || this.completing) break
+        await this.uploadOne(item)
+      }
+    },
+    async uploadOne(item) {
+      if (this.state !== 'ready' || this.completing || !this.session) return
+      const validationError = validateBackgroundFile(item.file)
+      if (validationError) {
+        item.status = 'failed'
+        item.error = validationError
+        return
+      }
+      item.status = 'uploading'
+      item.progress = 0
+      item.error = ''
+      const context = this.sessionContext()
+      const onProgress = event => {
+        if (!this.isSessionContextCurrent(context) || item.status !== 'uploading') return
+        if (!event || !event.total) return
+        item.progress = Math.max(0, Math.min(100, Math.round(event.loaded * 100 / event.total)))
+      }
+      try {
+        const response = await uploadPublicTeamBackgroundFile(context.uploadToken, item.file, onProgress)
+        if (!this.isSessionContextCurrent(context)) return
+        this.requireSuccess(response, '上传失败,请重试')
+        item.status = 'success'
+        item.progress = 100
+      } catch (reason) {
+        if (!this.isSessionContextCurrent(context)) return
+        item.status = 'failed'
+        item.error = this.errorMessage(reason, '上传失败,请重试')
+        if (this.isSessionFailure(reason)) this.expireSession()
+      }
+    },
+    retryUpload(item) {
+      if (this.state !== 'ready' || this.completing) return Promise.resolve()
+      return this.uploadOne(item)
+    },
+    removeQueueItem(item) {
+      if (this.state !== 'ready' || this.completing) return
+      const index = this.queue.findIndex(candidate => candidate.id === item.id)
+      if (index >= 0) this.queue.splice(index, 1)
+    },
+    async completeUpload() {
+      if (this.state !== 'ready' || this.completing || !this.session ||
+        this.queue.some(item => item.status === 'uploading')) return
+      const context = this.sessionContext()
+      this.completing = true
+      try {
+        const response = await completePublicUpload(context.uploadToken)
+        if (!this.isSessionContextCurrent(context)) return
+        this.requireSuccess(response, '完成上传失败,请重试')
+        this.successCount = this.queue.filter(item => item.status === 'success').length
+        this.completing = false
+        this.clearCountdown()
+        this.invalidateAsyncWork()
+        this.state = 'completed'
+      } catch (reason) {
+        if (!this.isSessionContextCurrent(context)) return
+        this.completing = false
+        if (this.isSessionFailure(reason)) {
+          this.expireSession()
+          return
+        }
+        this.$message.error(this.errorMessage(reason, '完成上传失败,请重试'))
+      }
+    },
+    sessionContext() {
+      return {
+        generation: this.asyncGeneration,
+        uploadToken: this.session && this.session.uploadToken
+      }
+    },
+    isSessionContextCurrent(context) {
+      return this.state === 'ready' &&
+        this.session &&
+        context.generation === this.asyncGeneration &&
+        context.uploadToken === this.session.uploadToken
+    },
+    isSessionFailure(reason) {
+      return /会话|过期|失效|已完成|团队不存在|无权|权限|上传链接|token|令牌/i.test(this.errorMessage(reason, ''))
+    },
+    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
+    },
+    errorMessage(reason, fallbackMessage) {
+      if (typeof reason === 'string') return reason || fallbackMessage
+      return (reason && (reason.msg || reason.message)) || fallbackMessage
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.public-upload-page {
+  display: flex;
+  min-height: 100vh;
+  padding: 48px 20px;
+  align-items: flex-start;
+  justify-content: center;
+  box-sizing: border-box;
+  background: #f5f7fa;
+}
+
+.public-upload-card {
+  width: 100%;
+  max-width: 720px;
+  padding: 32px;
+  box-sizing: border-box;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 8px 30px rgba(31, 45, 61, 0.08);
+}
+
+.terminal-card {
+  color: #606266;
+  text-align: center;
+}
+
+.terminal-card__icon {
+  color: #409eff;
+  font-size: 42px;
+}
+
+.terminal-card__icon.is-success {
+  color: #67c23a;
+}
+
+.code-card {
+  max-width: 480px;
+  text-align: center;
+}
+
+.code-input {
+  margin: 18px 0;
+}
+
+.ready-card__header p,
+.muted-text {
+  color: #909399;
+}
+
+.session-expiry {
+  display: flex;
+  padding: 12px 16px;
+  margin: 20px 0;
+  justify-content: space-between;
+  color: #606266;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+.upload-picker {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.file-input {
+  display: none;
+}
+
+.primary-action {
+  width: 100%;
+  margin-top: 18px;
+}
+
+@media (max-width: 600px) {
+  .public-upload-page {
+    padding: 20px 12px;
+  }
+
+  .public-upload-card {
+    padding: 22px 18px;
+  }
+
+  .session-expiry,
+  .upload-picker {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+}
+</style>

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

@@ -0,0 +1,431 @@
+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 routerPath = path.resolve(__dirname, '../../src/router/index.js')
+const pagePath = path.resolve(__dirname, '../../src/views/pages/team-background-upload.vue')
+
+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 transformModule(source) {
+  return babel.transformSync(source, {
+    babelrc: false,
+    configFile: false,
+    plugins: [require.resolve('@babel/plugin-transform-modules-commonjs')]
+  }).code
+}
+
+function evaluateModule(source, mocks) {
+  const code = transformModule(source)
+  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
+}
+
+function loadRouter() {
+  const http = { get: jest.fn(() => new Promise(() => {})) }
+  const Vue = { use: jest.fn(), prototype: { $message: { error: jest.fn() } } }
+
+  class Router {
+    constructor(options) {
+      this.options = options
+      this.app = { $options: { store: { state: { sidebarMenuActiveName: [] } } } }
+    }
+
+    beforeEach(guard) {
+      this.guard = guard
+    }
+
+    onError(handler) {
+      this.errorHandler = handler
+    }
+
+    addRoutes() {}
+
+    push() {
+      return Promise.resolve()
+    }
+  }
+
+  const previousWindow = global.window
+  global.window = {
+    SITE_CONFIG: {
+      dynamicMenuRoutesHasAdded: false,
+      dynamicRoutes: [],
+      menuList: [],
+      contentTabDefault: {}
+    }
+  }
+  try {
+    const exports = evaluateModule(readIfPresent(routerPath), {
+      vue: Vue,
+      'vue-router': Router,
+      '@/utils/request': http
+    })
+    return { ...exports, http }
+  } finally {
+    global.window = previousWindow
+  }
+}
+
+function expectPage(source) {
+  expect(source).not.toBe('')
+  return source !== ''
+}
+
+function loadPageOptions(api) {
+  const source = readIfPresent(pagePath)
+  const descriptor = parseComponent(source)
+  return evaluateModule(descriptor.script.content, {
+    '@/api/teamBackgroundFile': api,
+    '@/utils/teamBackgroundFile': { ACCEPT, formatFileSize, validateBackgroundFile },
+    '@/components/team-background/TeamBackgroundUploadQueue.vue': {}
+  }).default
+}
+
+function createPageVm(api, overrides = {}) {
+  const options = loadPageOptions(api)
+  const vm = {
+    ...options.data(),
+    $route: { query: {} },
+    $message: { error: jest.fn(), success: jest.fn() },
+    $refs: { fileInput: { value: 'selected', click: jest.fn() } },
+    ...options.methods,
+    ...overrides
+  }
+  vm.$componentOptions = options
+  return vm
+}
+
+function computed(vm, name) {
+  return vm.$componentOptions.computed[name].call(vm)
+}
+
+describe('public team background upload route', () => {
+  test('is a page route outside the authenticated layout and short-circuits before menu requests', () => {
+    const { default: router, pageRoutes, moduleRoutes, http } = loadRouter()
+    const route = pageRoutes.find(candidate => candidate.path === '/team-background-upload')
+    const next = jest.fn()
+
+    expect(route).toMatchObject({
+      path: '/team-background-upload',
+      name: 'team-background-upload',
+      meta: { title: '上传团队背景资料', public: true }
+    })
+    expect(moduleRoutes.children.some(candidate => candidate.path === route.path)).toBe(false)
+
+    router.guard({ path: route.path, matched: [{ meta: route.meta }] }, {}, next)
+
+    expect(next).toHaveBeenCalledTimes(1)
+    expect(next).toHaveBeenCalledWith()
+    expect(http.get).not.toHaveBeenCalled()
+  })
+})
+
+describe('public team background upload page state machine', () => {
+  const source = readIfPresent(pagePath)
+  let api
+
+  beforeEach(() => {
+    api = {
+      completePublicUpload: jest.fn(),
+      getPublicUploadSession: jest.fn(),
+      uploadPublicTeamBackgroundFile: jest.fn(),
+      verifyPublicUploadCode: jest.fn()
+    }
+  })
+
+  afterEach(() => {
+    jest.useRealTimers()
+  })
+
+  test('auto-validates a token and ignores its deferred result after expiry', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    api.getPublicUploadSession.mockReturnValue(request.promise)
+    const vm = createPageVm(api, { $route: { query: { token: 'link-token' } } })
+
+    const loading = vm.$componentOptions.created.call(vm)
+    expect(vm.state).toBe('verifying')
+    expect(api.getPublicUploadSession).toHaveBeenCalledWith('link-token')
+
+    vm.expireSession()
+    request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2099-01-01T00:00:00Z' } })
+    await loading
+
+    expect(vm.state).toBe('expired')
+    expect(vm.session).toBeNull()
+  })
+
+  test('shows code entry without a token and accepts exactly six ASCII digits', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    api.verifyPublicUploadCode.mockReturnValue(request.promise)
+    const vm = createPageVm(api)
+
+    await vm.$componentOptions.created.call(vm)
+    expect(vm.state).toBe('code')
+    expect(api.getPublicUploadSession).not.toHaveBeenCalled()
+
+    for (const invalidCode of ['12345', '1234567', '12345a', '123456']) {
+      vm.code = invalidCode
+      await vm.verifyCode()
+    }
+    expect(api.verifyPublicUploadCode).not.toHaveBeenCalled()
+    expect(vm.$message.error).toHaveBeenCalledTimes(4)
+    expect(vm.$message.error).toHaveBeenLastCalledWith('请输入6位数字验证码')
+
+    vm.code = '123456'
+    const verifying = vm.verifyCode()
+    expect(vm.state).toBe('verifying')
+    request.resolve({ code: 0, data: { teamName: 'Alpha', uploadToken: 'verified-token', expiresAt: '2099-01-01T00:00:00Z' } })
+    await verifying
+
+    expect(api.verifyPublicUploadCode).toHaveBeenCalledWith('123456')
+    expect(vm.state).toBe('ready')
+    expect(vm.session.uploadToken).toBe('verified-token')
+    vm.$componentOptions.beforeDestroy.call(vm)
+  })
+
+  test('ignores a deferred code response after the page becomes expired', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    api.verifyPublicUploadCode.mockReturnValue(request.promise)
+    const vm = createPageVm(api, { code: '123456', state: 'code' })
+
+    const verifying = vm.verifyCode()
+    vm.expireSession()
+    request.resolve({ code: 0, data: { teamName: 'Late', uploadToken: 'late-token', expiresAt: '2099-01-01T00:00:00Z' } })
+    await verifying
+
+    expect(vm.state).toBe('expired')
+    expect(vm.session).toBeNull()
+  })
+
+  test('uses the fixed expiry for a one-second countdown and clears timers', () => {
+    if (!expectPage(source)) return
+    jest.useFakeTimers()
+    jest.setSystemTime(new Date('2026-07-21T10:00:00.000Z'))
+    const vm = createPageVm(api)
+
+    vm.activateSession({ teamName: 'Alpha', uploadToken: 'upload-token', expiresAt: '2026-07-21T10:00:02.500Z' })
+    expect(vm.remainingSeconds).toBe(2)
+    expect(jest.getTimerCount()).toBe(1)
+
+    jest.advanceTimersByTime(1000)
+    expect(vm.remainingSeconds).toBe(1)
+    jest.advanceTimersByTime(1000)
+    expect(vm.remainingSeconds).toBe(0)
+    expect(vm.state).toBe('expired')
+    expect(jest.getTimerCount()).toBe(0)
+
+    vm.activateSession({ teamName: 'Beta', uploadToken: 'other-token', expiresAt: '2026-07-21T10:01:00.000Z' })
+    expect(jest.getTimerCount()).toBe(1)
+    vm.$componentOptions.beforeDestroy.call(vm)
+    expect(jest.getTimerCount()).toBe(0)
+  })
+
+  test('creates the exact queue shape and rejects more than ten files before enqueueing', async () => {
+    if (!expectPage(source)) return
+    const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
+    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()
+
+    await vm.handleFileChange({ target: { files: Array.from({ length: 11 }, (_, index) => ({ name: `${index}.pdf`, size: 1 })) } })
+    expect(vm.queue).toEqual([])
+    expect(api.uploadPublicTeamBackgroundFile).not.toHaveBeenCalled()
+    expect(vm.$message.error).toHaveBeenCalledWith('一次最多选择 10 个文件')
+  })
+
+  test('reports progress, retains independent results and retries only the failed item', async () => {
+    if (!expectPage(source)) return
+    const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
+    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: 'retry.docx', size: 1 }, name: 'retry.docx', progress: 0, status: 'pending', error: '' }
+    vm.queue = [success, failure]
+    api.uploadPublicTeamBackgroundFile
+      .mockImplementationOnce((_token, _file, onProgress) => {
+        onProgress({ loaded: 1, total: 2 })
+        return Promise.resolve({ code: 0, data: {} })
+      })
+      .mockRejectedValueOnce({ msg: '网络失败' })
+
+    await vm.uploadOne(success)
+    await vm.uploadOne(failure)
+
+    expect(success).toMatchObject({ status: 'success', progress: 100, error: '' })
+    expect(failure).toMatchObject({ status: 'failed', progress: 0, error: '网络失败' })
+    expect(vm.queue).toEqual([success, failure])
+    expect(api.uploadPublicTeamBackgroundFile.mock.calls[0]).toEqual([
+      'upload-token',
+      success.file,
+      expect.any(Function)
+    ])
+
+    api.uploadPublicTeamBackgroundFile.mockImplementationOnce((_token, _file, onProgress) => {
+      onProgress({ loaded: 3, total: 4 })
+      expect(failure.progress).toBe(75)
+      return Promise.resolve({ code: 0, data: {} })
+    })
+    await vm.retryUpload(failure)
+
+    expect(success).toMatchObject({ status: 'success', progress: 100 })
+    expect(failure).toMatchObject({ status: 'success', progress: 100, error: '' })
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenLastCalledWith('upload-token', failure.file, expect.any(Function))
+  })
+
+  test('a session-level upload failure expires the page and stops later files in the batch', async () => {
+    if (!expectPage(source)) return
+    api.uploadPublicTeamBackgroundFile.mockRejectedValue({ msg: '上传会话已过期' })
+    const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
+    const files = [{ name: 'one.pdf', size: 1 }, { name: 'two.pdf', size: 1 }]
+
+    await vm.handleFileChange({ target: { files } })
+
+    expect(api.uploadPublicTeamBackgroundFile).toHaveBeenCalledTimes(1)
+    expect(vm.queue[0]).toMatchObject({ status: 'failed', error: '上传会话已过期' })
+    expect(vm.queue[1]).toMatchObject({ status: 'pending' })
+    expect(vm.state).toBe('expired')
+  })
+
+  test('late upload progress and success cannot overwrite expiry', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    let onProgress
+    api.uploadPublicTeamBackgroundFile.mockImplementation((_token, _file, callback) => {
+      onProgress = callback
+      return request.promise
+    })
+    const vm = createPageVm(api, { state: 'ready', session: { uploadToken: 'upload-token' } })
+    const item = { id: '1-0', file: { name: 'slow.pdf', size: 1 }, name: 'slow.pdf', progress: 0, status: 'pending', error: '' }
+
+    const uploading = vm.uploadOne(item)
+    onProgress({ loaded: 1, total: 5 })
+    expect(item.progress).toBe(20)
+    vm.expireSession()
+    onProgress({ loaded: 4, total: 5 })
+    request.resolve({ code: 0, data: {} })
+    await uploading
+
+    expect(vm.state).toBe('expired')
+    expect(item).toMatchObject({ status: 'uploading', progress: 20 })
+  })
+
+  test('completion uses only the upload token, counts successes and freezes further uploads', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    api.completePublicUpload.mockReturnValue(request.promise)
+    const vm = createPageVm(api, {
+      state: 'ready',
+      session: { uploadToken: 'upload-token' },
+      queue: [
+        { id: '1', status: 'success' },
+        { id: '2', status: 'failed' },
+        { id: '3', status: 'success' }
+      ]
+    })
+    vm.uploadOne = jest.fn()
+
+    expect(computed(vm, 'uploadDisabled')).toBe(false)
+    const completing = vm.completeUpload()
+    expect(vm.completing).toBe(true)
+    expect(computed(vm, 'uploadDisabled')).toBe(true)
+    request.resolve({ code: 0 })
+    await completing
+
+    expect(api.completePublicUpload).toHaveBeenCalledWith('upload-token')
+    expect(vm.successCount).toBe(2)
+    expect(vm.state).toBe('completed')
+    expect(computed(vm, 'uploadDisabled')).toBe(true)
+
+    await vm.handleFileChange({ target: { files: [{ name: 'late.pdf', size: 1 }] } })
+    await vm.retryUpload(vm.queue[1])
+    await vm.completeUpload()
+    expect(vm.uploadOne).not.toHaveBeenCalled()
+    expect(api.completePublicUpload).toHaveBeenCalledTimes(1)
+  })
+
+  test('completion closes empty and all-failed queues with a zero success count', async () => {
+    if (!expectPage(source)) return
+    api.completePublicUpload.mockResolvedValue({ code: 0 })
+
+    for (const queue of [[], [{ id: 'failed', status: 'failed' }]]) {
+      const vm = createPageVm(api, {
+        state: 'ready',
+        session: { uploadToken: 'upload-token' },
+        queue
+      })
+
+      expect(computed({ ...vm, uploadDisabled: false }, 'completeDisabled')).toBe(false)
+      await vm.completeUpload()
+
+      expect(vm.state).toBe('completed')
+      expect(vm.successCount).toBe(0)
+    }
+    expect(api.completePublicUpload).toHaveBeenCalledTimes(2)
+    expect(api.completePublicUpload).toHaveBeenNthCalledWith(1, 'upload-token')
+    expect(api.completePublicUpload).toHaveBeenNthCalledWith(2, 'upload-token')
+  })
+
+  test('late completion cannot overwrite timer expiry', async () => {
+    if (!expectPage(source)) return
+    const request = deferred()
+    api.completePublicUpload.mockReturnValue(request.promise)
+    const vm = createPageVm(api, {
+      state: 'ready',
+      session: { uploadToken: 'upload-token' },
+      queue: [{ id: '1', status: 'success' }]
+    })
+
+    const completing = vm.completeUpload()
+    vm.expireSession()
+    request.resolve({ code: 0 })
+    await completing
+
+    expect(vm.state).toBe('expired')
+    expect(vm.successCount).toBe(0)
+  })
+
+  test('ready and terminal templates expose only the approved public information', () => {
+    if (!expectPage(source)) return
+
+    const template = parseComponent(source).template.content
+    const sessionFields = Array.from(template.matchAll(/session\.([A-Za-z_$][\w$]*)/g), match => match[1])
+
+    expect(source).toContain('session.teamName')
+    expect(source).toContain('session.expiresAt')
+    expect(source).toContain('remainingSeconds')
+    expect(source).toContain('TeamBackgroundUploadQueue')
+    expect(source).toContain('请返回小程序或管理端重新生成上传链接')
+    expect(source).toMatch(/state === 'expired'[\s\S]*请返回小程序或管理端重新生成上传链接/)
+    expect(source).toMatch(/state === 'completed'[\s\S]*successCount/)
+    expect(source).not.toMatch(/teamDescription|teamMembers|coachName|existingFiles|fileList|listTeamBackgroundFiles/)
+    expect(new Set(sessionFields)).toEqual(new Set(['teamName', 'expiresAt']))
+    expect(template).not.toMatch(/{{\s*session\s*}}/)
+  })
+})