Ver código fonte

fix: isolate team background workflows

Developer 3 dias atrás
pai
commit
6b4486ed8f

+ 14 - 6
components/CusHeader/index.vue

@@ -40,9 +40,13 @@
 					color: '#111111'
 				}
 			},
-			backAlert:{
-				typeof:Boolean,
-				default:false
+			backAlert:{
+				typeof:Boolean,
+				default:false
+			},
+			interceptBack:{
+				type:Boolean,
+				default:false
 			}
 		},
 		data() {
@@ -54,8 +58,12 @@
 			}
 		},
 		methods: {
-			toBack(url) {
-				if(this.backAlert){
+			toBack(url) {
+				if(this.interceptBack){
+					this.$emit('back')
+					return
+				}
+				if(this.backAlert){
 					uni.showModal({
 						title:'温馨提示',
 						content:'您正在填写问卷中,系统仅保留当前问卷的作答进度,是否确认返回?',
@@ -95,4 +103,4 @@
 	.u-navbar--fixed {
 		z-index: 99999 !important;
 	}
-</style>
+</style>

+ 168 - 40
components/CusTeamBackgroundFiles/index.vue

@@ -87,7 +87,8 @@ const {
 	createEmptyTextPreviewState,
 	createTextPreviewState,
 	getSessionRemainingSeconds,
-	getUploadableBackgroundFiles
+	getUploadableBackgroundFiles,
+	hasUnresolvedBackgroundFiles
 } = teamBackgroundRules
 
 export default {
@@ -99,12 +100,17 @@ export default {
 	data() {
 		return {
 			boundTeamId: '',
+			contextGeneration: 0,
+			uploadGeneration: 0,
 			session: null,
 			pcDialogVisible: false,
 			pcUploadDeferred: false,
 			deferredPcResolve: null,
+			deferredPcReject: null,
+			deferredPcPromise: null,
 			files: [],
 			count: this.initialCount || 0,
+			filesReady: false,
 			queue: [],
 			remainingSeconds: 0,
 			sessionTimer: null,
@@ -113,6 +119,7 @@ export default {
 			previewRequestId: 0,
 			previewingId: '',
 			uploadPromise: null,
+			uploadPromiseContext: '',
 			hasLoadedFiles: false,
 			componentDestroyed: false,
 			textPreview: createEmptyTextPreviewState()
@@ -129,9 +136,9 @@ export default {
 		teamId: {
 			immediate: true,
 			handler(value) {
-				this.boundTeamId = value || ''
-				this.hasLoadedFiles = false
-				this.refreshRequestId += 1
+				const nextTeamId = value || ''
+				if (String(nextTeamId) === String(this.boundTeamId)) return
+				this.resetTeamContext(nextTeamId)
 				if (value && this.editable) {
 					this.refreshFiles(value).catch(error => this.showError(error, '资料列表加载失败'))
 				}
@@ -146,14 +153,57 @@ export default {
 		this.sessionRequestId += 1
 		this.refreshRequestId += 1
 		this.previewRequestId += 1
+		this.uploadGeneration += 1
 		this.clearSessionTimer()
-		this.closeTextPreview()
-		this.resolveDeferredPcUpload()
+		this.textPreview = createEmptyTextPreviewState()
+		this.settleDeferredPcUpload(new Error('背景资料组件已关闭'))
+		this.uploadPromise = null
+		this.uploadPromiseContext = ''
 	},
 	methods: {
+		sameTeamId(first, second) {
+			return String(first || '') === String(second || '')
+		},
 		activeTeamId(teamId) {
 			return teamId || this.teamId || this.boundTeamId
 		},
+		isCurrentContext(teamId, generation) {
+			return !this.componentDestroyed && generation === this.contextGeneration
+				&& this.sameTeamId(teamId, this.activeTeamId())
+		},
+		resetTeamContext(teamId) {
+			this.contextGeneration += 1
+			this.uploadGeneration += 1
+			this.refreshRequestId += 1
+			this.sessionRequestId += 1
+			this.previewRequestId += 1
+			this.clearSessionTimer()
+			this.settleDeferredPcUpload(new Error('团队上下文已变更'))
+			this.boundTeamId = teamId || ''
+			this.files = []
+			this.count = 0
+			this.filesReady = false
+			this.hasLoadedFiles = false
+			this.queue = []
+			this.session = null
+			this.pcDialogVisible = false
+			this.pcUploadDeferred = false
+			this.remainingSeconds = 0
+			this.previewingId = ''
+			this.textPreview = createEmptyTextPreviewState()
+			this.uploadPromise = null
+			this.uploadPromiseContext = ''
+			this.$emit('countChange', 0)
+		},
+		bindDeferredTeam(teamId) {
+			if (!this.boundTeamId && !this.teamId) {
+				this.boundTeamId = teamId
+				this.contextGeneration += 1
+				this.uploadGeneration += 1
+				return
+			}
+			if (!this.sameTeamId(teamId, this.activeTeamId())) this.resetTeamContext(teamId)
+		},
 		showError(error, fallback) {
 			if (this.componentDestroyed) return
 			this.$showToast(error && (error.message || error.msg) || fallback)
@@ -198,19 +248,31 @@ export default {
 			return this.flushPendingFiles(teamId)
 		},
 		async flushPendingFiles(teamId) {
-			const targetTeamId = this.activeTeamId(teamId)
+			const targetTeamId = teamId || this.activeTeamId()
 			if (!targetTeamId) return { success: 0, failed: 0 }
-			this.boundTeamId = targetTeamId
-			if (this.uploadPromise) return this.uploadPromise
-			const upload = this.performPendingUploads(targetTeamId)
+			this.bindDeferredTeam(targetTeamId)
+			const generation = this.contextGeneration
+			const uploadGeneration = this.uploadGeneration
+			const contextKey = `${generation}:${targetTeamId}:${uploadGeneration}`
+			if (this.uploadPromise && this.uploadPromiseContext === contextKey) return this.uploadPromise
+			const queueSnapshot = getUploadableBackgroundFiles(this.queue).slice()
+			const upload = this.performPendingUploads(
+				targetTeamId, queueSnapshot, generation, uploadGeneration
+			)
 			this.uploadPromise = upload
+			this.uploadPromiseContext = contextKey
 			let result
 			try {
 				result = await upload
 			} finally {
-				if (this.uploadPromise === upload) this.uploadPromise = null
+				if (this.isCurrentContext(targetTeamId, generation)
+					&& this.uploadPromise === upload && this.uploadPromiseContext === contextKey) {
+					this.uploadPromise = null
+					this.uploadPromiseContext = ''
+				}
 			}
-			if (!this.componentDestroyed && this.queue.some(item => item.status === 'pending')) {
+			if (this.isCurrentContext(targetTeamId, generation)
+				&& this.queue.some(item => item.status === 'pending')) {
 				const laterResult = await this.flushPendingFiles(targetTeamId)
 				return {
 					success: result.success + laterResult.success,
@@ -219,31 +281,45 @@ export default {
 			}
 			return result
 		},
-		async performPendingUploads(teamId) {
+		async performPendingUploads(teamId, queueSnapshot, generation, uploadGeneration) {
 			let success = 0
 			let failed = 0
-			for (const item of getUploadableBackgroundFiles(this.queue)) {
-				if (this.componentDestroyed) break
+			for (const item of queueSnapshot) {
+				if (!this.isCurrentContext(teamId, generation)
+					|| uploadGeneration !== this.uploadGeneration) break
 				item.status = 'uploading'
 				item.error = ''
 				try {
 					await uploadTeamBackgroundFile(teamId, item.path, progress => {
-						if (!this.componentDestroyed) item.progress = progress
+						if (this.isCurrentContext(teamId, generation)
+							&& uploadGeneration === this.uploadGeneration
+							&& this.queue.includes(item)) item.progress = progress
 					})
-					if (this.componentDestroyed) break
+					if (!this.isCurrentContext(teamId, generation)
+						|| uploadGeneration !== this.uploadGeneration
+						|| !this.queue.includes(item)) break
 					item.status = 'success'
 					item.progress = 100
 					success += 1
 				} catch (error) {
-					if (this.componentDestroyed) break
+					if (!this.isCurrentContext(teamId, generation)
+						|| uploadGeneration !== this.uploadGeneration
+						|| !this.queue.includes(item)) break
 					item.status = 'failed'
 					item.error = error && (error.message || error.msg) || '上传失败,请重试'
 					failed += 1
 				}
 			}
-			if (!this.componentDestroyed) await this.refreshFiles(teamId)
+			if (this.isCurrentContext(teamId, generation)
+				&& uploadGeneration === this.uploadGeneration) await this.refreshFiles(teamId)
 			return { success, failed }
 		},
+		hasUnresolvedFiles() {
+			return hasUnresolvedBackgroundFiles(this.queue)
+		},
+		isUploadActive() {
+			return Boolean(this.uploadPromise)
+		},
 		retryItem(item) {
 			if (item.validationError || item.status === 'uploading') return
 			item.status = 'pending'
@@ -253,33 +329,49 @@ export default {
 			if (teamId) this.uploadPending(teamId).catch(error => this.showError(error, '上传失败,请重试'))
 		},
 		async refreshFiles(teamId = this.activeTeamId()) {
-			if (!teamId || !this.editable) return []
+			if (!teamId || !this.editable || !this.sameTeamId(teamId, this.activeTeamId())) return []
+			const generation = this.contextGeneration
 			const requestId = ++this.refreshRequestId
+			this.files = []
+			this.count = 0
+			this.filesReady = false
+			this.$emit('countChange', 0)
 			let files
 			try {
 				files = await listTeamBackgroundFiles(teamId)
 			} catch (error) {
-				if (this.componentDestroyed || requestId !== this.refreshRequestId) return this.files
+				if (!this.isCurrentContext(teamId, generation)
+					|| requestId !== this.refreshRequestId) return this.files
 				throw error
 			}
-			if (this.componentDestroyed || requestId !== this.refreshRequestId
-				|| String(teamId) !== String(this.activeTeamId())) return this.files
+			if (!this.isCurrentContext(teamId, generation)
+				|| requestId !== this.refreshRequestId) return this.files
 			this.files = Array.isArray(files) ? files : []
 			this.count = this.files.length
+			this.filesReady = true
 			this.hasLoadedFiles = true
 			this.$emit('countChange', this.count)
 			return this.files
 		},
+		isCurrentFile(file, teamId, generation) {
+			if (!this.filesReady || !file || !this.isCurrentContext(teamId, generation)) return false
+			return this.files.some(current => String(current.id) === String(file.id))
+		},
 		async previewFile(file) {
 			const teamId = this.activeTeamId()
-			if (!teamId || !file || file.id === undefined || file.id === null) return
+			const generation = this.contextGeneration
+			if (!teamId || file.id === undefined || file.id === null
+				|| !this.isCurrentFile(file, teamId, generation)) return
 			const requestId = ++this.previewRequestId
 			this.previewingId = file.id
 			try {
 				const result = await downloadTeamBackgroundFile(teamId, file.id)
-				if (this.componentDestroyed || requestId !== this.previewRequestId) return
+				if (requestId !== this.previewRequestId
+					|| !this.isCurrentFile(file, teamId, generation)) return
 				if (result && result.mode === 'text') {
 					this.textPreview = createTextPreviewState(result, file.fileName || file.name)
+				} else if (result && result.mode === 'document') {
+					await this.openDocumentPreview(result)
 				}
 			} catch (error) {
 				if (requestId === this.previewRequestId) this.showError(error, '文件打开失败')
@@ -287,6 +379,20 @@ export default {
 				if (requestId === this.previewRequestId) this.previewingId = ''
 			}
 		},
+		openDocumentPreview(result) {
+			return new Promise((resolve, reject) => {
+				if (typeof wx === 'undefined' || typeof wx.openDocument !== 'function') {
+					reject(new Error('当前环境不支持文档预览'))
+					return
+				}
+				wx.openDocument({
+					filePath: result.filePath,
+					showMenu: true,
+					success: resolve,
+					fail: reject
+				})
+			})
+		},
 		closeTextPreview() {
 			this.previewRequestId += 1
 			this.previewingId = ''
@@ -294,15 +400,18 @@ export default {
 		},
 		removeFile(file) {
 			const teamId = this.activeTeamId()
-			if (!teamId || !file || file.id === undefined || file.id === null) return
+			const generation = this.contextGeneration
+			if (!teamId || file.id === undefined || file.id === null
+				|| !this.isCurrentFile(file, teamId, generation)) return
 			uni.showModal({
 				title: '停用资料',
 				content: `确定停用“${file.fileName || file.name || '该文件'}”吗?`,
 				confirmColor: '#199C9C',
 				success: async result => {
-					if (!result.confirm) return
+					if (!result.confirm || !this.isCurrentFile(file, teamId, generation)) return
 					try {
 						await disableTeamBackgroundFile(teamId, file.id)
+						if (!this.isCurrentContext(teamId, generation)) return
 						await this.refreshFiles(teamId)
 						if (!this.componentDestroyed) this.$showToast('已停用')
 					} catch (error) {
@@ -318,20 +427,23 @@ export default {
 				this.$showToast('将先保存团队,再生成电脑上传方式')
 				return
 			}
-			this.pcUploadDeferred = false
 			this.openPcSession(teamId).catch(error => this.showError(error, '上传会话创建失败'))
 		},
 		async openPcSession(teamId) {
+			if (!teamId || !this.sameTeamId(teamId, this.activeTeamId())) return null
+			const generation = this.contextGeneration
 			const requestId = ++this.sessionRequestId
-			this.clearSessionTimer()
 			let session
 			try {
 				session = await createTeamBackgroundSession(teamId)
 			} catch (error) {
-				if (this.componentDestroyed || requestId !== this.sessionRequestId) return null
+				if (!this.isCurrentContext(teamId, generation)
+					|| requestId !== this.sessionRequestId) return null
 				throw error
 			}
-			if (this.componentDestroyed || requestId !== this.sessionRequestId) return null
+			if (!this.isCurrentContext(teamId, generation)
+				|| requestId !== this.sessionRequestId) return null
+			this.clearSessionTimer()
 			this.boundTeamId = teamId
 			this.session = session
 			this.pcDialogVisible = true
@@ -369,17 +481,25 @@ export default {
 			return this.pcUploadDeferred
 		},
 		async waitForDeferredPcUpload(teamId) {
-			this.resolveDeferredPcUpload()
+			if (this.deferredPcPromise) return this.deferredPcPromise
 			const session = await this.openPcSession(teamId)
 			if (!session || this.componentDestroyed) return
-			this.pcUploadDeferred = true
-			return new Promise(resolve => {
+			this.deferredPcPromise = new Promise((resolve, reject) => {
 				this.deferredPcResolve = resolve
+				this.deferredPcReject = reject
 			})
+			this.pcUploadDeferred = false
+			return this.deferredPcPromise
 		},
-		resolveDeferredPcUpload() {
-			if (this.deferredPcResolve) this.deferredPcResolve()
+		settleDeferredPcUpload(error) {
+			if (error && this.deferredPcReject) this.deferredPcReject(error)
+			else if (this.deferredPcResolve) this.deferredPcResolve()
 			this.deferredPcResolve = null
+			this.deferredPcReject = null
+			this.deferredPcPromise = null
+		},
+		resolveDeferredPcUpload() {
+			this.settleDeferredPcUpload()
 			this.pcUploadDeferred = false
 		},
 		closePcDialog() {
@@ -389,15 +509,23 @@ export default {
 			this.session = null
 			this.remainingSeconds = 0
 		},
-		finishDeferredPcUpload() {
+		async finishDeferredPcUpload() {
 			const teamId = this.activeTeamId()
+			const hasDeferredWaiter = Boolean(this.deferredPcPromise)
 			this.closePcDialog()
-			this.resolveDeferredPcUpload()
-			if (teamId) this.refreshFiles(teamId).catch(error => this.showError(error, '资料列表刷新失败'))
+			this.pcUploadDeferred = false
+			try {
+				if (teamId) await this.refreshFiles(teamId)
+				this.settleDeferredPcUpload()
+			} catch(error) {
+				if (hasDeferredWaiter) this.settleDeferredPcUpload(error)
+				else this.showError(error, '资料列表刷新失败')
+			}
 		},
 		skipDeferredPcUpload() {
 			this.closePcDialog()
-			this.resolveDeferredPcUpload()
+			this.settleDeferredPcUpload()
+			this.pcUploadDeferred = false
 		}
 	}
 }

+ 33 - 8
components/CusTeamInfoFill/index.vue

@@ -70,7 +70,7 @@
 			</view>
 		</view>
 		<cus-team-background-files ref="backgroundRef" :teamId="teamId" :initialCount="teamInfo.backgroundFileCount || 0" @countChange="backgroundCountChange" />
-		<view class="zt_btn" @click="handleConfirm">{{confirmText}}</view>
+		<view class="zt_btn" :class="{ disabled: confirming || submitting }" @click="handleConfirm">{{confirming || submitting ? '处理中...' : confirmText}}</view>
 		<view class="dialog" v-if="areaShow">
 			<view class="dialog-box">
 				<cus-province-city-area @cancel="areaShow=false" @confirm="areaConfirm"></cus-province-city-area>
@@ -98,7 +98,8 @@
 				typeof:Boolean,
 				default:false
 			},
-			teamId: { type: [String, Number], default: '' }
+			teamId: { type: [String, Number], default: '' },
+			submitting: { type: Boolean, default: false }
 		},
 		options: { styleIsolation: 'shared' },
 		data(){
@@ -129,7 +130,8 @@
 				teamLevelShow:false,
 				teamLevelData:[],
 				teamLevelText:'请选择',
-				isqtype:false
+				isqtype:false,
+				confirming:false
 			}
 		},
 		mounted() {
@@ -148,6 +150,12 @@
 			waitForDeferredPcUpload(teamId) {
 				return this.$refs.backgroundRef.waitForDeferredPcUpload(teamId)
 			},
+			hasUnresolvedBackgroundFiles() {
+				return this.$refs.backgroundRef.hasUnresolvedFiles()
+			},
+			isBackgroundUploadActive() {
+				return this.$refs.backgroundRef.isUploadActive()
+			},
 			getTeamScaleData(){
 				this.$api.get('/getListByType/team_scale').then(({data:res})=>{
 					if(res.code!==0) return this.$showToast(res.msg)
@@ -226,7 +234,8 @@
 				this[key2] = e.name;
 				this[key3] = false;
 			},
-			handleConfirm(){
+			async handleConfirm(){
+				if(this.confirming || this.submitting) return
 				if(!this.teamInfo.teamName) return this.$showToast('请输入团队名称')
 				if(!this.teamInfo.enterpriseName) return this.$showToast('请输入公司名称')
 				const enterpriseWebsite = String(this.teamInfo.enterpriseWebsite || '').trim()
@@ -242,8 +251,21 @@
 					// if(!this.teamInfo.hierarchy) return this.$showToast('请选择团队层级')
 					if(!this.teamInfo.brief) return this.$showToast('请输入团队简介')
 				}
-				
-				this.$emit('handleConfirm',createTeamSubmitPayload(this.teamInfo))
+				this.confirming = true
+				try {
+					if(this.teamId){
+						const uploadResult = await this.flushBackgroundFiles(this.teamId)
+						if(uploadResult.failed > 0 || this.hasUnresolvedBackgroundFiles()){
+							this.$showToast('仍有背景资料未上传成功,请重试后再保存')
+							return
+						}
+					}
+					this.$emit('handleConfirm',createTeamSubmitPayload(this.teamInfo))
+				} catch(error) {
+					this.$showToast(error && (error.message || error.msg) || '背景资料处理失败,请重试')
+				} finally {
+					this.confirming = false
+				}
 			},
 			areaConfirm(e){
 				const { provinceId, cityId,districtId,provinceName,cityName,districtName } = e
@@ -353,12 +375,15 @@
 			}
 		}
 		
-		.zt_btn{
+		.zt_btn{
 			width: calc(100% - 100rpx);
 			position: fixed;
 			left: 50rpx;
 			bottom: 54rpx;
-			z-index: 1000;
+			z-index: 1000;
+			&.disabled{
+				opacity: .55;
+			}
 		}
 	}
 </style>

+ 1 - 6
http/teamBackgroundFile.js

@@ -179,12 +179,7 @@ export function downloadTeamBackgroundFile(teamId, fileId) {
 				const fileName = getDownloadFileName(headers)
 				const extension = getFileExtension(fileName)
 				if (OPEN_DOCUMENT_EXTENSIONS.includes(extension)) {
-					wx.openDocument({
-						filePath: result.tempFilePath,
-						showMenu: true,
-						success: resolve,
-						fail: reject
-					})
+					resolve({ mode: 'document', fileName, filePath: result.tempFilePath })
 					return
 				}
 				if (TEXT_PREVIEW_EXTENSIONS.includes(extension)) {

+ 41 - 9
pagesHome/components/createList.vue

@@ -67,13 +67,13 @@
 				</view>
 			</view>
 		</view>
-		<view class="dialog adffc" v-if="teamInfoShow" @click.self="teamInfoShow=false">
-			<view class="dialog-background" @click="teamInfoShow=false"></view>
+		<view class="dialog adffc" v-if="teamInfoShow" @click.self="requestCloseTeamInfo">
+			<view class="dialog-background" @click="requestCloseTeamInfo"></view>
 			<view class="dialog-box adffc">
 				<view class="dialog-box-title">团队信息</view>
-				<image class="dialog-box-close" :src="imgBase + 'remind_close.png'" @click="teamInfoShow = false"></image>
+				<image class="dialog-box-close" :src="imgBase + 'remind_close.png'" @click="requestCloseTeamInfo"></image>
 				<view class="dialog-box-teaminfo">
-					<cus-team-info-fill ref="teamRef" :teamId="dto && dto.teamId" confirmText="保存" @handleConfirm="handleConfirm"></cus-team-info-fill>
+					<cus-team-info-fill ref="teamRef" :teamId="dto && dto.teamId" :submitting="savingTeamInfo" confirmText="保存" @handleConfirm="handleConfirm"></cus-team-info-fill>
 				</view>
 			</view>
 		</view>
@@ -163,10 +163,32 @@ export default {
 			categoryData:[],
 			delMemberIds:[],
 			testShow:false,
-			tipText:''
+			tipText:'',
+			savingTeamInfo:false
 		}
 	},
 	methods:{
+		hasPendingTeamBackgroundFiles(){
+			const team = this.$refs.teamRef
+			return Boolean(team && (team.hasUnresolvedBackgroundFiles()
+				|| team.isBackgroundUploadActive()))
+		},
+		requestCloseTeamInfo(){
+			if(this.savingTeamInfo) return this.$showToast('团队正在保存,请稍候')
+			if(!this.hasPendingTeamBackgroundFiles()){
+				this.teamInfoShow = false
+				return
+			}
+			uni.showModal({
+				title:'背景资料尚未完成',
+				content:'仍有背景资料正在上传或等待重试,关闭将丢弃待处理队列;已发出的上传可能仍会完成。确定关闭吗?',
+				confirmText:'仍然关闭',
+				confirmColor:'#FD4F66',
+				success: result => {
+					if(result.confirm) this.teamInfoShow = false
+				}
+			})
+		},
 		scrolltolower(){
 			this.$emit('scrolltolower')
 		},
@@ -271,12 +293,22 @@ export default {
 				this.teamUserShow = true
 			})
 		},
-		handleConfirm(data){
-			this.$api.put('/core/user/team',data).then(({data:res})=>{
-				if(res.code!==0) return this.$showToast(res.msg)
+		async handleConfirm(data){
+			if(this.savingTeamInfo) return
+			this.savingTeamInfo = true
+			try {
+				const { data:res } = await this.$api.put('/core/user/team',data)
+				if(res.code!==0){
+					this.$showToast(res.msg)
+					return
+				}
 				this.$showToast('保存成功')
 				this.teamInfoShow = false;
-			})
+			} catch(error) {
+				this.$showToast(error && (error.message || error.msg) || '团队保存失败,请重试')
+			} finally {
+				this.savingTeamInfo = false
+			}
 		},
 		handleAnswer(item){
 			uni.navigateTo({

+ 42 - 12
pagesMy/teamEdit.vue

@@ -1,7 +1,7 @@
 <template>
 	<view class="default_page" :style="{'min-height':h+'px', 'padding-top':mt+'px'}">
-		<cus-header title='编辑团队'></cus-header>
-		<cus-team-info-fill ref="teamRef" :teamId="id" @handleConfirm="handleConfirm" :confirmText="confirmText"></cus-team-info-fill>
+		<cus-header title='编辑团队' :interceptBack="true" @back="requestBack"></cus-header>
+		<cus-team-info-fill ref="teamRef" :teamId="id" :submitting="saving" @handleConfirm="handleConfirm" :confirmText="confirmText"></cus-team-info-fill>
 		<view class="dialog adffcacjc" v-if="show">
 			<view class="dbox">
 				<view class="dbox-title">温馨提示</view>
@@ -27,7 +27,8 @@
 				scaleName:'',
 				hierarchyName:'',
 				submitDto:null,
-				show:false
+				show:false,
+				saving:false
 			}
 		},
 		onLoad(options) {
@@ -45,7 +46,25 @@
 				})
 			})
 		},
-		methods:{
+		methods:{
+			hasPendingBackgroundFiles(){
+				const team = this.$refs.teamRef
+				return Boolean(team && (team.hasUnresolvedBackgroundFiles()
+					|| team.isBackgroundUploadActive()))
+			},
+			requestBack(){
+				if(this.saving) return this.$showToast('团队正在保存,请稍候')
+				if(!this.hasPendingBackgroundFiles()) return uni.navigateBack()
+				uni.showModal({
+					title:'背景资料尚未完成',
+					content:'仍有背景资料正在上传或等待重试,返回将丢弃待处理队列;已发出的上传可能仍会完成。确定返回吗?',
+					confirmText:'仍然返回',
+					confirmColor:'#FD4F66',
+					success: result => {
+						if(result.confirm) uni.navigateBack()
+					}
+				})
+			},
 			getDetail(){
 				this.$api.get(`/core/user/team/${this.id}`).then(({data:res})=>{
 					if(res.code!==0) return this.$showToast(res.msg)
@@ -65,17 +84,28 @@
 				this.submitDto = data;
 				this.show = true;
 			},
-			editConfirm(){
-				this.$api.put('/core/user/team',this.submitDto).then(({data:res})=>{
-					if(res.code!==0) return this.$showToast(res.msg)
-					this.$showToast('编辑成功')
+			async editConfirm(){
+				if(this.saving) return
+				this.saving = true
+				try {
+					const { data:res } = await this.$api.put('/core/user/team',this.submitDto)
+					if(res.code!==0){
+						this.$showToast(res.msg)
+						return
+					}
+					this.show = false
+					this.$showToast('编辑成功')
 					setTimeout(()=>{
 						uni.redirectTo({
 							url:'/pagesMy/team'
-						})
-					},1500)
-				})
-			}
+						})
+					},1500)
+				} catch(error) {
+					this.$showToast(error && (error.message || error.msg) || '团队编辑失败,请重试')
+				} finally {
+					this.saving = false
+				}
+			}
 		}
 	}
 </script>

+ 53 - 13
pagesPublish/fillTeamInfo.vue

@@ -1,13 +1,21 @@
 <template>
 	<view class="default_page" :style="{'min-height':h+'px', 'padding-top':mt+'px'}">
 		<cus-header title='填写所在团队信息'></cus-header>
-		<cus-team-info-fill ref="teamRef" @handleConfirm="handleConfirm" :confirmText="confirmText" :qtype="qtype"></cus-team-info-fill>
+		<cus-team-info-fill ref="teamRef" :submitting="creationState.submitting || creationState.completed" @handleConfirm="handleConfirm" :confirmText="confirmText" :qtype="qtype"></cus-team-info-fill>
 	</view>
 </template>
 
-<script>
-	import CusTeamInfoFill from '@/components/CusTeamInfoFill/index.vue'
-	export default {
+<script>
+	import CusTeamInfoFill from '@/components/CusTeamInfoFill/index.vue'
+	import teamBackgroundRules from '@/utils/teamBackgroundFile.js'
+	const {
+		beginTeamSubmission,
+		createTeamCreationState,
+		finishTeamSubmission,
+		rememberCreatedTeam,
+		setTeamSubmissionStage
+	} = teamBackgroundRules
+	export default {
 		components:{ CusTeamInfoFill },
 		data(){
 			return {
@@ -16,7 +24,8 @@
 				next:'',
 				questionnaireId:'',
 				confirmText:'下一步',
-				qtype:''
+				qtype:'',
+				creationState:createTeamCreationState()
 			}
 		},
 		onLoad(options) {
@@ -30,24 +39,55 @@
 		},
 		methods:{
 			async handleConfirm(team){
+				if(!beginTeamSubmission(this.creationState)) return
 				team.questionnaireId = this.questionnaireId;
 				try {
-					const { data:res } = await this.$api.post('/core/user/team',team)
-					if(res.code!==0) return this.$showToast(res.msg)
-					const teamId = res.data && res.data.teamId
-					if(!teamId) return this.$showToast('团队保存失败,请重试')
+					let teamId = this.creationState.createdTeamId
+					if(!teamId){
+						setTeamSubmissionStage(this.creationState, 'save')
+						const { data:res } = await this.$api.post('/core/user/team',team)
+						if(res.code!==0){
+							this.$showToast(res.msg || '团队保存失败,请重试')
+							return
+						}
+						teamId = res.data && res.data.teamId
+						if(!teamId){
+							this.$showToast('团队保存未返回团队ID,请重试')
+							return
+						}
+						rememberCreatedTeam(this.creationState, teamId)
+					}
+
+					setTeamSubmissionStage(this.creationState, 'upload')
 					const uploadResult = await this.$refs.teamRef.flushBackgroundFiles(teamId)
-					if(uploadResult.failed > 0){
-						await this.$showModal(`${uploadResult.failed}个背景资料上传失败,可重试后继续`)
+					if(uploadResult.failed > 0 || this.$refs.teamRef.hasUnresolvedBackgroundFiles()){
+						await this.$showModal('仍有背景资料未上传成功,请重试后继续;团队已保存,无需再次创建')
+						return
 					}
 					if(this.$refs.teamRef.hasDeferredPcUpload()){
+						setTeamSubmissionStage(this.creationState, 'pcSession')
 						await this.$refs.teamRef.waitForDeferredPcUpload(teamId)
 					}
+					setTeamSubmissionStage(this.creationState, this.next ? 'publish' : 'navigate')
 					await this.continueAfterTeamCreated(teamId)
+					finishTeamSubmission(this.creationState, true)
 				} catch(error) {
-					this.$showToast(error && (error.message || error.msg) || '团队保存失败,请重试')
+					this.showStageError(error)
+				} finally {
+					finishTeamSubmission(this.creationState)
 				}
 			},
+			showStageError(error) {
+				const prefix = {
+					save:'团队保存失败',
+					upload:'背景资料处理失败',
+					pcSession:'电脑上传流程失败',
+					publish:'问卷发布失败',
+					navigate:'后续操作失败'
+				}[this.creationState.stage] || '操作失败'
+				const message = error && (error.message || error.msg)
+				this.$showToast(message ? `${prefix}:${message}` : `${prefix},请重试`)
+			},
 			async continueAfterTeamCreated(teamId) {
 				if(this.next){
 					const response = await this.$api.post('/core/team/questionnaire/publish',{
@@ -60,7 +100,7 @@
 						type:1
 					})
 					const result = response.data
-					if(result.code!==0) return this.$showToast(result.msg)
+					if(result.code!==0) throw new Error(result.msg || '发布请求失败')
 					this.$showToast('保存成功,即将填写问卷')
 					setTimeout(()=>{
 						uni.removeStorageSync('newUser')

+ 519 - 8
tests/teamBackgroundFile.test.js

@@ -88,6 +88,38 @@ assert.deepEqual(rules.createEmptyTextPreviewState(), {
 })
 assert.notEqual(rules.createEmptyTextPreviewState(), rules.createEmptyTextPreviewState())
 
+const creationState = rules.createTeamCreationState()
+assert.deepEqual(creationState, {
+	createdTeamId: '',
+	submitting: false,
+	stage: 'idle',
+	completed: false
+})
+assert.equal(rules.beginTeamSubmission(creationState), true)
+assert.equal(rules.beginTeamSubmission(creationState), false, 'a second submission must be locked')
+assert.equal(rules.rememberCreatedTeam(creationState, 88), 88)
+assert.equal(rules.rememberCreatedTeam(creationState, 99), 88, 'the first server team id must be retained')
+rules.setTeamSubmissionStage(creationState, 'upload')
+assert.equal(creationState.stage, 'upload')
+rules.finishTeamSubmission(creationState)
+assert.equal(creationState.submitting, false)
+assert.equal(rules.beginTeamSubmission(creationState), true, 'a failed later stage can resume')
+rules.finishTeamSubmission(creationState, true)
+assert.equal(creationState.completed, true)
+assert.equal(rules.beginTeamSubmission(creationState), false, 'a completed flow cannot be scheduled twice')
+
+assert.equal(rules.hasUnresolvedBackgroundFiles([
+	{ status: 'success', validationError: '' },
+	{ status: 'failed', validationError: '文件类型不支持' }
+]), false)
+for (const item of [
+	{ status: 'pending', validationError: '' },
+	{ status: 'uploading', validationError: '' },
+	{ status: 'failed', validationError: '' }
+]) {
+	assert.equal(rules.hasUnresolvedBackgroundFiles([item]), true)
+}
+
 console.log('team background rules: PASS')
 
 const transportPath = path.join(__dirname, '../http/teamBackgroundFile.js')
@@ -304,11 +336,13 @@ async function testDownloadTransport() {
 			'CONTENT-disPOSITION': 'attachment; filename="download.pdf"'
 		}
 	})
-	assert.equal(await successHarness.promise, 'opened')
+	const documentPreview = await successHarness.promise
+	assert.equal(documentPreview.mode, 'document')
+	assert.equal(documentPreview.fileName, 'download.pdf')
+	assert.equal(documentPreview.filePath, '/tmp/download.pdf')
 	assert.equal(successHarness.request().url, 'https://api.example.test/app/core/user/team/12/background-files/34/download')
 	assert.equal(successHarness.request().header.token, 'token-456')
-	assert.equal(successHarness.openRequest().filePath, '/tmp/download.pdf')
-	assert.equal(successHarness.openRequest().showMenu, true)
+	assert.equal(successHarness.openRequest(), undefined, 'transport must not open a document before component context checks')
 	assertNoIdentityFields(successHarness.request())
 
 	const resultHeaderHarness = await createDownloadHarness({
@@ -322,7 +356,11 @@ async function testDownloadTransport() {
 		},
 		withHeadersListener: false
 	})
-	assert.equal(await resultHeaderHarness.promise, 'opened')
+	const resultHeaderPreview = await resultHeaderHarness.promise
+	assert.equal(resultHeaderPreview.mode, 'document')
+	assert.equal(resultHeaderPreview.fileName, 'result-header.docx')
+	assert.equal(resultHeaderPreview.filePath, '/tmp/result-header.docx')
+	assert.equal(resultHeaderHarness.openRequest(), undefined)
 	assert.equal(resultHeaderHarness.fileSystemManagerCalls(), 0)
 
 	const txtHarness = await createDownloadHarness({
@@ -351,6 +389,35 @@ async function testDownloadTransport() {
 	assert.equal(txtHarness.readRequest().position, 0)
 	assert.equal(txtHarness.readRequest().length, 5)
 
+	const emptyTextHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/empty.txt' },
+		responseHeaders: {
+			'Content-Type': 'text/plain',
+			'Content-Disposition': 'attachment; filename="empty.txt"'
+		},
+		fileSize: 0
+	})
+	const emptyTextPreview = await emptyTextHarness.promise
+	assert.equal(emptyTextPreview.mode, 'text')
+	assert.equal(emptyTextPreview.fileName, 'empty.txt')
+	assert.equal(emptyTextPreview.content, '')
+	assert.equal(emptyTextPreview.truncated, false)
+	assert.equal(emptyTextHarness.readRequest(), undefined)
+
+	const invalidEncodedNameHarness = await createDownloadHarness({
+		result: { statusCode: 200, tempFilePath: '/tmp/fallback.txt' },
+		responseHeaders: {
+			'Content-Type': 'text/plain',
+			'Content-Disposition': 'attachment; filename="fallback.txt"; '
+				+ "filename*=UTF-8''%E0%A4%A"
+		},
+		fileSize: 8,
+		fileContent: 'fallback'
+	})
+	const invalidEncodedNamePreview = await invalidEncodedNameHarness.promise
+	assert.equal(invalidEncodedNamePreview.fileName, 'fallback.txt')
+	assert.equal(invalidEncodedNamePreview.content, 'fallback')
+
 	const mdContent = 'm'.repeat(TEXT_PREVIEW_LIMIT)
 	const mdHarness = await createDownloadHarness({
 		result: { statusCode: 200, tempFilePath: '/tmp/guide.md' },
@@ -457,16 +524,451 @@ async function testDownloadTransport() {
 	const downloadFailureHarness = await createDownloadHarness({ downloadError })
 	await assert.rejects(downloadFailureHarness.promise, /download network error/)
 
-	const openError = new Error('open document error')
-	const openFailureHarness = await createDownloadHarness({
+	const noTransportOpenHarness = await createDownloadHarness({
 		result: { statusCode: 200, tempFilePath: '/tmp/download.pdf' },
 		responseHeaders: {
 			'Content-Type': 'application/pdf',
 			'Content-Disposition': 'attachment; filename="download.pdf"'
 		},
-		openError
+		openError: new Error('must not be reached')
+	})
+	assert.equal((await noTransportOpenHarness.promise).mode, 'document')
+	assert.equal(noTransportOpenHarness.openRequest(), undefined)
+}
+
+function readSfcScript(filePath) {
+	const source = fs.readFileSync(filePath, 'utf8')
+	const openingTag = '<script>'
+	const start = source.indexOf(openingTag)
+	const end = source.indexOf('</script>', start + openingTag.length)
+	if (start < 0 || end < 0) throw new Error(`missing script block: ${filePath}`)
+	return source.slice(start + openingTag.length, end)
+}
+
+async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRules = rules } = {}) {
+	const context = vm.createContext({
+		uni,
+		wx,
+		Date,
+		Promise,
+		String,
+		Number,
+		Boolean,
+		Object,
+		Array,
+		setInterval,
+		clearInterval
+	})
+	const vueStub = new vm.SyntheticModule(['default'], function () {
+		this.setExport('default', {})
+	}, { context, identifier: 'test:vue-component' })
+	const rulesStub = new vm.SyntheticModule(['default'], function () {
+		this.setExport('default', apiRules)
+	}, { context, identifier: 'test:team-background-rules' })
+	const httpNames = [
+		'createTeamBackgroundSession',
+		'disableTeamBackgroundFile',
+		'downloadTeamBackgroundFile',
+		'listTeamBackgroundFiles',
+		'uploadTeamBackgroundFile'
+	]
+	const httpStub = new vm.SyntheticModule(httpNames, function () {
+		for (const name of httpNames) {
+			this.setExport(name, http[name] || (() => Promise.resolve()))
+		}
+	}, { context, identifier: 'test:team-background-http' })
+	const componentModule = new vm.SourceTextModule(readSfcScript(filePath), {
+		context,
+		identifier: pathToFileURL(filePath).href
+	})
+	await componentModule.link(specifier => {
+		if (specifier === '@/http/teamBackgroundFile.js') return httpStub
+		if (specifier === '@/utils/teamBackgroundFile.js') return rulesStub
+		if (specifier.endsWith('.vue')) return vueStub
+		throw new Error(`unexpected component import: ${specifier}`)
+	})
+	await componentModule.evaluate()
+	return componentModule.namespace.default
+}
+
+function instantiateComponent(component, overrides = {}) {
+	const base = Object.assign({ initialCount: 0 }, overrides)
+	const data = typeof component.data === 'function' ? component.data.call(base) : {}
+	const instance = Object.assign(base, data, overrides)
+	for (const [name, method] of Object.entries(component.methods || {})) {
+		instance[name] = method.bind(instance)
+	}
+	return instance
+}
+
+function createDeferred() {
+	let resolve
+	let reject
+	const promise = new Promise((resolvePromise, rejectPromise) => {
+		resolve = resolvePromise
+		reject = rejectPromise
+	})
+	return { promise, resolve, reject }
+}
+
+const backgroundComponentPath = path.join(__dirname, '../components/CusTeamBackgroundFiles/index.vue')
+const teamFillComponentPath = path.join(__dirname, '../components/CusTeamInfoFill/index.vue')
+const createTeamPagePath = path.join(__dirname, '../pagesPublish/fillTeamInfo.vue')
+const createListComponentPath = path.join(__dirname, '../pagesHome/components/createList.vue')
+const teamEditPagePath = path.join(__dirname, '../pagesMy/teamEdit.vue')
+const headerComponentPath = path.join(__dirname, '../components/CusHeader/index.vue')
+
+async function testDeferredPcSelectionSurvivesSessionFailure() {
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession: () => Promise.reject(new Error('session unavailable'))
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '',
+		editable: true,
+		boundTeamId: 12,
+		pcUploadDeferred: true,
+		$showToast() {},
+		$emit() {}
+	})
+	await assert.rejects(instance.waitForDeferredPcUpload(12), /session unavailable/)
+	assert.equal(instance.pcUploadDeferred, true)
+	assert.equal(instance.pcDialogVisible, false)
+	assert.equal(instance.deferredPcResolve, null)
+}
+
+async function testSessionRefreshFailureKeepsOldTimer() {
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession: () => Promise.reject(new Error('refresh failed'))
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, boundTeamId: 12, $showToast() {}, $emit() {}
+	})
+	const oldSession = { code: '111111', expiresAt: '2099-01-01 00:00:00' }
+	const oldTimer = setInterval(() => {}, 60 * 1000)
+	instance.session = oldSession
+	instance.sessionTimer = oldTimer
+	try {
+		await assert.rejects(instance.openPcSession(12), /refresh failed/)
+		assert.equal(instance.session, oldSession)
+		assert.equal(instance.sessionTimer, oldTimer)
+	} finally {
+		clearInterval(oldTimer)
+		instance.sessionTimer = null
+	}
+}
+
+async function testDeferredPcFinishWaitsForListRefresh() {
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession: () => Promise.resolve({
+				code: '123456',
+				uploadUrl: 'https://upload.example/',
+				expiresAt: '2099-01-01 00:00:00'
+			}),
+			listTeamBackgroundFiles: () => Promise.reject(new Error('list refresh failed'))
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, boundTeamId: 12, pcUploadDeferred: true,
+		$showToast() {}, $emit() {}
+	})
+	const waiting = instance.waitForDeferredPcUpload(12)
+	await new Promise(resolve => setImmediate(resolve))
+	assert.equal(typeof instance.deferredPcResolve, 'function')
+	await instance.finishDeferredPcUpload()
+	await assert.rejects(waiting, /list refresh failed/)
+}
+
+async function testTeamContextResetAndUploadIsolation() {
+	const uploads = []
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			uploadTeamBackgroundFile(teamId, filePath, onProgress) {
+				const deferred = createDeferred()
+				uploads.push({ teamId, filePath, onProgress, deferred })
+				return deferred.promise
+			},
+			listTeamBackgroundFiles: () => Promise.resolve([])
+		}
+	})
+	let resolverCalls = 0
+	const instance = instantiateComponent(component, {
+		teamId: '',
+		editable: true,
+		$showToast() {},
+		$emit() {}
+	})
+	instance.resetTeamContext(1)
+	const oldItem = rules.createBackgroundQueueItem(
+		{ name: 'old.pdf', path: '/tmp/old.pdf', size: 1 }, 'old'
+	)
+	instance.queue.push(oldItem)
+	instance.deferredPcResolve = () => { resolverCalls += 1 }
+	const oldFlush = instance.flushPendingFiles(1)
+	await new Promise(resolve => setImmediate(resolve))
+	assert.equal(uploads.length, 1)
+
+	instance.files = [{ id: 11, fileName: 'old.pdf' }]
+	instance.count = 1
+	instance.filesReady = true
+	instance.session = { code: '111111' }
+	instance.pcDialogVisible = true
+	instance.textPreview = rules.createTextPreviewState({ fileName: 'old.txt', content: 'old' })
+	instance.resetTeamContext(2)
+	assert.equal(instance.boundTeamId, 2)
+	assert.equal(instance.files.length, 0)
+	assert.equal(instance.count, 0)
+	assert.equal(instance.queue.length, 0)
+	assert.equal(instance.filesReady, false)
+	assert.equal(instance.session, null)
+	assert.equal(instance.pcDialogVisible, false)
+	assert.equal(instance.textPreview.content, '')
+	assert.equal(resolverCalls, 1)
+
+	const newItem = rules.createBackgroundQueueItem(
+		{ name: 'new.pdf', path: '/tmp/new.pdf', size: 1 }, 'new'
+	)
+	instance.queue.push(newItem)
+	const newFlush = instance.flushPendingFiles(2)
+	await new Promise(resolve => setImmediate(resolve))
+	assert.equal(uploads.length, 2, 'the new team must not reuse the old upload promise')
+	assert.deepEqual(uploads.map(item => item.teamId), [1, 2])
+	uploads[0].onProgress(75)
+	assert.equal(oldItem.progress, 0, 'stale upload progress must not mutate the old item after reset')
+	uploads[0].deferred.resolve({ id: 101 })
+	await oldFlush
+	assert.equal(newItem.status, 'uploading')
+	uploads[1].deferred.resolve({ id: 202 })
+	await newFlush
+	assert.equal(newItem.status, 'success')
+}
+
+async function testFileActionsWaitForCurrentListAndDocumentContext() {
+	const listDeferred = createDeferred()
+	const downloadDeferred = createDeferred()
+	const opened = []
+	const toasts = []
+	let downloadCalls = 0
+	const wx = {
+		openDocument(options) {
+			opened.push(options.filePath)
+			setImmediate(() => options.success())
+		}
+	}
+	const component = await loadVueComponent(backgroundComponentPath, {
+		wx,
+		http: {
+			listTeamBackgroundFiles: () => listDeferred.promise,
+			downloadTeamBackgroundFile: () => {
+				downloadCalls += 1
+				return downloadDeferred.promise
+			}
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '',
+		editable: true,
+		$showToast(message) { toasts.push(message) },
+		$emit() {}
+	})
+	instance.resetTeamContext(3)
+	const oldFile = { id: 31, fileName: 'old.pdf' }
+	instance.files = [oldFile]
+	instance.filesReady = true
+	const refresh = instance.refreshFiles(3)
+	await instance.previewFile(oldFile)
+	assert.equal(downloadCalls, 0, 'old file ids must be disabled while the current list is loading')
+	listDeferred.resolve([oldFile])
+	await refresh
+
+	const latePreview = instance.previewFile(oldFile)
+	assert.equal(downloadCalls, 1)
+	instance.resetTeamContext(4)
+	downloadDeferred.resolve({ mode: 'document', fileName: 'old.pdf', filePath: '/tmp/old.pdf' })
+	await latePreview
+	assert.deepEqual(opened, [], 'a stale download must not open a native document')
+
+	const validComponent = await loadVueComponent(backgroundComponentPath, {
+		wx,
+		http: {
+			downloadTeamBackgroundFile: () => Promise.resolve({
+				mode: 'document', fileName: 'current.pdf', filePath: '/tmp/current.pdf'
+			})
+		}
+	})
+	const validInstance = instantiateComponent(validComponent, {
+		teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
+	})
+	validInstance.resetTeamContext(5)
+	const currentFile = { id: 51, fileName: 'current.pdf' }
+	validInstance.files = [currentFile]
+	validInstance.filesReady = true
+	await validInstance.previewFile(currentFile)
+	assert.deepEqual(opened, ['/tmp/current.pdf'])
+
+	const failingComponent = await loadVueComponent(backgroundComponentPath, {
+		wx: {
+			openDocument(options) { setImmediate(() => options.fail(new Error('open failed'))) }
+		},
+		http: {
+			downloadTeamBackgroundFile: () => Promise.resolve({
+				mode: 'document', fileName: 'broken.pdf', filePath: '/tmp/broken.pdf'
+			})
+		}
+	})
+	const failingInstance = instantiateComponent(failingComponent, {
+		teamId: '', editable: true, $showToast(message) { toasts.push(message) }, $emit() {}
+	})
+	failingInstance.resetTeamContext(6)
+	const brokenFile = { id: 61, fileName: 'broken.pdf' }
+	failingInstance.files = [brokenFile]
+	failingInstance.filesReady = true
+	await failingInstance.previewFile(brokenFile)
+	assert.ok(toasts.some(message => String(message).includes('open failed')))
+}
+
+async function testTeamFillFlushesBeforeEmitAndLocks() {
+	const component = await loadVueComponent(teamFillComponentPath)
+	const flushDeferred = createDeferred()
+	let flushCalls = 0
+	let emitCalls = 0
+	const instance = instantiateComponent(component, {
+		teamId: 12,
+		qtype: false,
+		$props: { qtype: false },
+		$showToast() {},
+		$emit() { emitCalls += 1 },
+		$refs: {
+			backgroundRef: {
+				flushPendingFiles() { flushCalls += 1; return flushDeferred.promise },
+				hasUnresolvedFiles() { return false }
+			}
+		}
+	})
+	instance.teamInfo = {
+		teamName: '团队', enterpriseName: '公司', enterpriseWebsite: '无',
+		districtId: 1, industryId: 2, functionIds: [], orgIds: []
+	}
+	const first = instance.handleConfirm()
+	const second = instance.handleConfirm()
+	assert.equal(flushCalls, 1)
+	assert.equal(emitCalls, 0)
+	flushDeferred.resolve({ success: 1, failed: 0 })
+	await Promise.all([first, second])
+	assert.equal(emitCalls, 1)
+}
+
+async function testCreatedTeamResumeAndSubmitLock() {
+	const component = await loadVueComponent(createTeamPagePath)
+	let teamPostCalls = 0
+	let flushCalls = 0
+	const toasts = []
+	const instance = instantiateComponent(component, {
+		$api: {
+			post(url) {
+				assert.equal(url, '/core/user/team')
+				teamPostCalls += 1
+				return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
+			}
+		},
+		$showToast(message) { toasts.push(message) },
+		$showModal: () => Promise.resolve(),
+		$refs: {
+			teamRef: {
+				flushBackgroundFiles() {
+					flushCalls += 1
+					if (flushCalls === 1) return Promise.reject(new Error('列表刷新失败'))
+					return Promise.resolve({ success: 0, failed: 0 })
+				},
+				hasUnresolvedBackgroundFiles: () => false,
+				hasDeferredPcUpload: () => false
+			}
+		}
+	})
+	let continuations = 0
+	instance.continueAfterTeamCreated = async teamId => {
+		assert.equal(teamId, 88)
+		continuations += 1
+	}
+	await instance.handleConfirm({ teamName: '团队' })
+	assert.equal(teamPostCalls, 1)
+	assert.equal(instance.creationState.createdTeamId, 88)
+	assert.ok(toasts.some(message => String(message).includes('背景资料')))
+	await instance.handleConfirm({ teamName: '团队' })
+	assert.equal(teamPostCalls, 1, 'retry after a later-stage failure must not POST a second team')
+	assert.equal(continuations, 1)
+
+	const lockedPost = createDeferred()
+	const lockedInstance = instantiateComponent(component, {
+		$api: { post: () => { teamPostCalls += 1; return lockedPost.promise } },
+		$showToast() {},
+		$showModal: () => Promise.resolve(),
+		$refs: { teamRef: {} }
+	})
+	const lockedFirst = lockedInstance.handleConfirm({ teamName: '团队' })
+	const callsAfterFirst = teamPostCalls
+	const lockedSecond = lockedInstance.handleConfirm({ teamName: '团队' })
+	assert.equal(teamPostCalls, callsAfterFirst, 'concurrent submission must be ignored')
+	lockedPost.resolve({ data: { code: 1, msg: '保存接口失败' } })
+	await Promise.all([lockedFirst, lockedSecond])
+}
+
+async function testCloseAndBackRequirePendingUploadConfirmation() {
+	let modalOptions
+	let navigateBackCalls = 0
+	const uni = {
+		showModal(options) { modalOptions = options },
+		navigateBack() { navigateBackCalls += 1 }
+	}
+	const createList = await loadVueComponent(createListComponentPath, { uni })
+	const createListInstance = instantiateComponent(createList, {
+		$imgBase: 'https://image.example/',
+		teamInfoShow: true,
+		$refs: {
+			teamRef: {
+				hasUnresolvedBackgroundFiles: () => true,
+				isBackgroundUploadActive: () => false
+			}
+		}
+	})
+	createListInstance.requestCloseTeamInfo()
+	assert.equal(createListInstance.teamInfoShow, true)
+	modalOptions.success({ confirm: false })
+	assert.equal(createListInstance.teamInfoShow, true)
+	createListInstance.requestCloseTeamInfo()
+	modalOptions.success({ confirm: true })
+	assert.equal(createListInstance.teamInfoShow, false)
+
+	const teamEdit = await loadVueComponent(teamEditPagePath, { uni })
+	const teamEditInstance = instantiateComponent(teamEdit, {
+		$refs: {
+			teamRef: {
+				hasUnresolvedBackgroundFiles: () => true,
+				isBackgroundUploadActive: () => true
+			}
+		}
+	})
+	teamEditInstance.requestBack()
+	assert.equal(navigateBackCalls, 0)
+	modalOptions.success({ confirm: false })
+	assert.equal(navigateBackCalls, 0)
+	teamEditInstance.requestBack()
+	modalOptions.success({ confirm: true })
+	assert.equal(navigateBackCalls, 1)
+
+	let headerBackEvents = 0
+	const header = await loadVueComponent(headerComponentPath, { uni })
+	const headerInstance = instantiateComponent(header, {
+		interceptBack: true,
+		$emit(name) { if (name === 'back') headerBackEvents += 1 }
 	})
-	await assert.rejects(openFailureHarness.promise, /open document error/)
+	headerInstance.toBack('')
+	assert.equal(headerBackEvents, 1)
+	assert.equal(navigateBackCalls, 1, 'intercepted header back must not navigate directly')
 }
 
 async function main() {
@@ -475,7 +977,16 @@ async function main() {
 	await testUploadTransport()
 	await testApiTransport()
 	await testDownloadTransport()
+	await testDeferredPcSelectionSurvivesSessionFailure()
+	await testSessionRefreshFailureKeepsOldTimer()
+	await testDeferredPcFinishWaitsForListRefresh()
+	await testTeamContextResetAndUploadIsolation()
+	await testFileActionsWaitForCurrentListAndDocumentContext()
+	await testTeamFillFlushesBeforeEmitAndLocks()
+	await testCreatedTeamResumeAndSubmitLock()
+	await testCloseAndBackRequirePendingUploadConfirmation()
 	console.log('team background transport: PASS')
+	console.log('team background component behavior: PASS')
 }
 
 main().catch(error => {

+ 40 - 1
utils/teamBackgroundFile.js

@@ -107,6 +107,39 @@ function createTextPreviewState(result, fallbackFileName) {
 	}
 }
 
+function createTeamCreationState() {
+	return { createdTeamId: '', submitting: false, stage: 'idle', completed: false }
+}
+
+function beginTeamSubmission(state) {
+	if (!state || state.submitting || state.completed) return false
+	state.submitting = true
+	return true
+}
+
+function rememberCreatedTeam(state, teamId) {
+	if (!state) return ''
+	if (!state.createdTeamId && teamId) state.createdTeamId = teamId
+	return state.createdTeamId
+}
+
+function setTeamSubmissionStage(state, stage) {
+	if (state) state.stage = stage || 'idle'
+}
+
+function finishTeamSubmission(state, completed = false) {
+	if (!state) return
+	state.submitting = false
+	state.completed = state.completed || completed
+	if (completed) state.stage = 'complete'
+}
+
+function hasUnresolvedBackgroundFiles(queue) {
+	return Array.isArray(queue) && queue.some(item => item.status === 'pending'
+		|| item.status === 'uploading'
+		|| (item.status === 'failed' && !item.validationError))
+}
+
 module.exports = {
 	ALLOWED_EXTENSIONS,
 	MAX_FILE_SIZE,
@@ -118,5 +151,11 @@ module.exports = {
 	getUploadableBackgroundFiles,
 	createTeamSubmitPayload,
 	createEmptyTextPreviewState,
-	createTextPreviewState
+	createTextPreviewState,
+	createTeamCreationState,
+	beginTeamSubmission,
+	rememberCreatedTeam,
+	setTeamSubmissionStage,
+	finishTeamSubmission,
+	hasUnresolvedBackgroundFiles
 }