Ver Fonte

fix: make team workflow navigation resumable

Developer há 3 dias atrás
pai
commit
225488d97d

+ 39 - 5
components/CusTeamBackgroundFiles/index.vue

@@ -9,7 +9,7 @@
 		</view>
 
 		<template v-if="editable">
-			<view class="background-files-pc" @click="usePcUpload">使用电脑上传</view>
+			<view class="background-files-pc" :class="{ disabled: sessionCreating }" @click="usePcUpload">{{ sessionCreating ? '生成中' : '使用电脑上传' }}</view>
 
 			<view v-if="files.length" class="file-list">
 				<view v-for="file in files" :key="file.id" class="file-row adfacjb">
@@ -50,7 +50,7 @@
 				<view class="session-link">{{ session && session.uploadUrl || '' }}</view>
 				<view class="session-actions adfacjb">
 					<view class="session-button secondary" :class="{ disabled: remainingSeconds === 0 }" @click="copyUploadUrl">复制链接</view>
-					<view class="session-button secondary" @click="refreshPcSession">重新生成</view>
+					<view class="session-button secondary" :class="{ disabled: sessionCreating }" @click="refreshPcSession">{{ sessionCreating ? '生成中' : '重新生成' }}</view>
 				</view>
 				<view class="session-actions adfacjb">
 					<view class="session-button primary" @click="finishDeferredPcUpload">完成并刷新</view>
@@ -114,6 +114,8 @@ export default {
 			queue: [],
 			remainingSeconds: 0,
 			sessionTimer: null,
+			sessionCreating: false,
+			sessionRequestsByTeam: {},
 			sessionRequestId: 0,
 			refreshRequestId: 0,
 			previewRequestId: 0,
@@ -155,6 +157,7 @@ export default {
 		this.previewRequestId += 1
 		this.uploadGeneration += 1
 		this.clearSessionTimer()
+		this.sessionCreating = false
 		this.textPreview = createEmptyTextPreviewState()
 		this.settleDeferredPcUpload(new Error('背景资料组件已关闭'))
 		this.uploadPromise = null
@@ -193,6 +196,7 @@ export default {
 			this.textPreview = createEmptyTextPreviewState()
 			this.uploadPromise = null
 			this.uploadPromiseContext = ''
+			this.updateSessionCreating()
 			this.$emit('countChange', 0)
 		},
 		bindDeferredTeam(teamId) {
@@ -427,15 +431,44 @@ export default {
 				this.$showToast('将先保存团队,再生成电脑上传方式')
 				return
 			}
+			if (this.sessionCreating) return
 			this.openPcSession(teamId).catch(error => this.showError(error, '上传会话创建失败'))
 		},
+		sessionTeamKey(teamId) {
+			return String(teamId || '')
+		},
+		updateSessionCreating() {
+			if (this.componentDestroyed) {
+				this.sessionCreating = false
+				return
+			}
+			const teamId = this.activeTeamId()
+			this.sessionCreating = Boolean(teamId
+				&& this.sessionRequestsByTeam[this.sessionTeamKey(teamId)])
+		},
+		getSessionRequest(teamId) {
+			const key = this.sessionTeamKey(teamId)
+			const existing = this.sessionRequestsByTeam[key]
+			if (existing) return existing
+			const request = createTeamBackgroundSession(teamId)
+			this.sessionRequestsByTeam[key] = request
+			this.updateSessionCreating()
+			const clearRequest = () => {
+				if (this.sessionRequestsByTeam[key] === request) {
+					delete this.sessionRequestsByTeam[key]
+					this.updateSessionCreating()
+				}
+			}
+			request.then(clearRequest, clearRequest)
+			return request
+		},
 		async openPcSession(teamId) {
 			if (!teamId || !this.sameTeamId(teamId, this.activeTeamId())) return null
 			const generation = this.contextGeneration
-			const requestId = ++this.sessionRequestId
+			const requestId = this.sessionRequestId
 			let session
 			try {
-				session = await createTeamBackgroundSession(teamId)
+				session = await this.getSessionRequest(teamId)
 			} catch (error) {
 				if (!this.isCurrentContext(teamId, generation)
 					|| requestId !== this.sessionRequestId) return null
@@ -474,7 +507,7 @@ export default {
 		},
 		refreshPcSession() {
 			const teamId = this.activeTeamId()
-			if (!teamId) return
+			if (!teamId || this.sessionCreating) return
 			this.openPcSession(teamId).catch(error => this.showError(error, '上传会话创建失败'))
 		},
 		hasDeferredPcUpload() {
@@ -564,6 +597,7 @@ export default {
 	text-align: right;
 	margin-top: 20rpx;
 }
+.background-files-pc.disabled { color: #b3bfc8; }
 .file-list, .queue-list {
 	margin-top: 24rpx;
 	border-top: 1rpx solid #efefef;

+ 77 - 14
pagesMy/teamEdit.vue

@@ -28,10 +28,16 @@
 				hierarchyName:'',
 				submitDto:null,
 				show:false,
-				saving:false
-			}
-		},
+				saving:false,
+				saveSucceeded:false,
+				saveStage:'idle',
+				navigationTimer:null,
+				navigationReject:null,
+				pageUnloaded:false
+			}
+		},
 		onLoad(options) {
+			this.pageUnloaded = false
 			this.id = options.id;
 			this.scaleName = options.scaleName;
 			this.hierarchyName = options.hierarchyName;
@@ -46,6 +52,10 @@
 				})
 			})
 		},
+		onUnload() {
+			this.pageUnloaded = true
+			this.cancelPendingRedirect(new Error('页面已离开'))
+		},
 		methods:{
 			hasPendingBackgroundFiles(){
 				const team = this.$refs.teamRef
@@ -88,23 +98,76 @@
 				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
+					if(!this.saveSucceeded) {
+						this.saveStage = 'save'
+						const { data:res } = await this.$api.put('/core/user/team',this.submitDto)
+						if(res.code!==0){
+							this.$showToast(res.msg)
+							return
+						}
+						this.saveSucceeded = true
+						this.show = false
+						this.$showToast('编辑成功')
 					}
-					this.show = false
-					this.$showToast('编辑成功')
-					setTimeout(()=>{
-						uni.redirectTo({
-							url:'/pagesMy/team'
+					this.saveStage = 'navigate'
+					await this.runDelayedRedirect(({ success, fail }) => {
+						uni.redirectTo({
+							url:'/pagesMy/team',
+							success,
+							fail
 						})
-					},1500)
+					})
 				} catch(error) {
-					this.$showToast(error && (error.message || error.msg) || '团队编辑失败,请重试')
+					if(!this.pageUnloaded) {
+						const fallback = this.saveStage === 'navigate' ? '页面跳转失败,请重试' : '团队编辑失败,请重试'
+						const message = error && (error.message || error.msg)
+						this.$showToast(message && this.saveStage === 'navigate' ? `页面跳转失败:${message}` : message || fallback)
+					}
 				} finally {
 					this.saving = false
 				}
+			},
+			runDelayedRedirect(invokeRedirect) {
+				return new Promise((resolve, reject) => {
+					if(this.pageUnloaded) {
+						reject(new Error('页面已离开'))
+						return
+					}
+					let settled = false
+					const settle = (callback, value) => {
+						if(settled) return
+						settled = true
+						this.navigationTimer = null
+						this.navigationReject = null
+						callback(value)
+					}
+					this.navigationReject = error => settle(reject, error)
+					this.navigationTimer = setTimeout(() => {
+						this.navigationTimer = null
+						if(this.pageUnloaded) {
+							settle(reject, new Error('页面已离开'))
+							return
+						}
+						try {
+							invokeRedirect({
+								success: result => {
+									if(this.pageUnloaded) settle(reject, new Error('页面已离开'))
+									else settle(resolve, result)
+								},
+								fail: error => settle(reject, new Error(error && (error.errMsg || error.message) || '跳转失败'))
+							})
+						} catch(error) {
+							settle(reject, error)
+						}
+					},1500)
+				})
+			},
+			cancelPendingRedirect(error) {
+				if(this.navigationTimer) clearTimeout(this.navigationTimer)
+				this.navigationTimer = null
+				const reject = this.navigationReject
+				this.navigationReject = null
+				if(reject) reject(error || new Error('跳转已取消'))
 			}
 		}
 	}

+ 115 - 24
pagesPublish/fillTeamInfo.vue

@@ -11,6 +11,8 @@
 	const {
 		beginTeamSubmission,
 		createTeamCreationState,
+		createTeamPayloadSnapshot,
+		createTeamSubmitPayload,
 		finishTeamSubmission,
 		rememberCreatedTeam,
 		setTeamSubmissionStage
@@ -25,27 +27,39 @@
 				questionnaireId:'',
 				confirmText:'下一步',
 				qtype:'',
-				creationState:createTeamCreationState()
-			}
-		},
-		onLoad(options) {
+				creationState:createTeamCreationState(),
+				navigationTimer:null,
+				navigationReject:null,
+				pageUnloaded:false
+			}
+		},
+		onLoad(options) {
+			this.pageUnloaded = false;
 			this.title = options.title||'';
 			this.type = options.type;
 			this.qtype = options.qtype||'';
 			this.questionnaireId = options.questionnaireId||'';
 			this.confirmText = options.type?'确定':'下一步';
-			this.next = options.next;
-			if(this.next) this.confirmText = '下一步';
-		},
+			this.next = options.next;
+			if(this.next) this.confirmText = '下一步';
+		},
+		onUnload() {
+			this.pageUnloaded = true
+			this.cancelPendingNavigation(new Error('页面已离开'))
+		},
 		methods:{
 			async handleConfirm(team){
 				if(!beginTeamSubmission(this.creationState)) return
-				team.questionnaireId = this.questionnaireId;
+				const payload = createTeamSubmitPayload(Object.assign({}, team, {
+					questionnaireId:this.questionnaireId
+				}))
+				const payloadSnapshot = createTeamPayloadSnapshot(payload)
 				try {
 					let teamId = this.creationState.createdTeamId
+					const resumeNavigation = this.creationState.stage === 'navigate'
 					if(!teamId){
 						setTeamSubmissionStage(this.creationState, 'save')
-						const { data:res } = await this.$api.post('/core/user/team',team)
+						const { data:res } = await this.$api.post('/core/user/team',payload)
 						if(res.code!==0){
 							this.$showToast(res.msg || '团队保存失败,请重试')
 							return
@@ -56,6 +70,28 @@
 							return
 						}
 						rememberCreatedTeam(this.creationState, teamId)
+						this.creationState.teamPayloadSnapshot = payloadSnapshot
+					} else if(this.creationState.teamPayloadSnapshot !== payloadSnapshot) {
+						setTeamSubmissionStage(this.creationState, 'save')
+						let res
+						try {
+							const response = await this.$api.put('/core/user/team', Object.assign({}, payload, {
+								id:teamId
+							}))
+							res = response.data
+							if(res.code!==0) throw new Error(res.msg || '团队更新请求失败')
+						} catch(error) {
+							if(resumeNavigation) setTeamSubmissionStage(this.creationState, 'navigate')
+							throw error
+						}
+						this.creationState.teamPayloadSnapshot = payloadSnapshot
+					}
+
+					if(resumeNavigation) {
+						setTeamSubmissionStage(this.creationState, 'navigate')
+						await this.continueAfterTeamCreated(teamId)
+						finishTeamSubmission(this.creationState, true)
+						return
 					}
 
 					setTeamSubmissionStage(this.creationState, 'upload')
@@ -68,7 +104,13 @@
 						setTeamSubmissionStage(this.creationState, 'pcSession')
 						await this.$refs.teamRef.waitForDeferredPcUpload(teamId)
 					}
-					setTeamSubmissionStage(this.creationState, this.next ? 'publish' : 'navigate')
+					if(this.next && !this.creationState.teamQuestionnaireId) {
+						setTeamSubmissionStage(this.creationState, 'publish')
+						const publishResult = await this.publishQuestionnaire(teamId)
+						this.creationState.publishResult = publishResult
+						this.creationState.teamQuestionnaireId = publishResult.data
+					}
+					setTeamSubmissionStage(this.creationState, 'navigate')
 					await this.continueAfterTeamCreated(teamId)
 					finishTeamSubmission(this.creationState, true)
 				} catch(error) {
@@ -78,19 +120,19 @@
 				}
 			},
 			showStageError(error) {
+				if(this.pageUnloaded) return
 				const prefix = {
 					save:'团队保存失败',
 					upload:'背景资料处理失败',
 					pcSession:'电脑上传流程失败',
 					publish:'问卷发布失败',
-					navigate:'后续操作失败'
+					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',{
+			async publishQuestionnaire(teamId) {
+				const response = await this.$api.post('/core/team/questionnaire/publish',{
 						answerSetting:1,
 						coachId:JSON.parse(uni.getStorageSync('userInfo')).id,
 						startTime:new Date().Format('yyyy-MM-dd hh:mm:ss'),
@@ -99,22 +141,71 @@
 						teamId,
 						type:1
 					})
-					const result = response.data
-					if(result.code!==0) throw new Error(result.msg || '发布请求失败')
+				const result = response.data
+				if(result.code!==0) throw new Error(result.msg || '发布请求失败')
+				return result
+			},
+			async continueAfterTeamCreated(teamId) {
+				if(this.next){
 					this.$showToast('保存成功,即将填写问卷')
-					setTimeout(()=>{
-						uni.removeStorageSync('newUser')
+					await this.runDelayedNavigation(({ success, fail }) => {
 						uni.navigateTo({
-							url:`/pagesPublish/questionnaireFill?teamQuestionnaireId=${result.data}&teamId=${teamId}&type=${this.type}&title=${this.title}`
+							url:`/pagesPublish/questionnaireFill?teamQuestionnaireId=${this.creationState.teamQuestionnaireId}&teamId=${teamId}&type=${this.type}&title=${this.title}`,
+							success,
+							fail
 						})
-					},1500)
+					})
+					uni.removeStorageSync('newUser')
 					return
 				}
 				this.$showToast('团队新增成功')
-				setTimeout(()=>{
-					this.getOpenerEventChannel().emit('saveTeamInfo')
-					uni.navigateBack()
-				},1500)
+				await this.runDelayedNavigation(({ success, fail }) => {
+					uni.navigateBack({ success, fail })
+				})
+				const eventChannel = this.getOpenerEventChannel()
+				if(eventChannel) eventChannel.emit('saveTeamInfo')
+			},
+			runDelayedNavigation(invokeNavigation) {
+				return new Promise((resolve, reject) => {
+					if(this.pageUnloaded) {
+						reject(new Error('页面已离开'))
+						return
+					}
+					let settled = false
+					const settle = (callback, value) => {
+						if(settled) return
+						settled = true
+						this.navigationTimer = null
+						this.navigationReject = null
+						callback(value)
+					}
+					this.navigationReject = error => settle(reject, error)
+					this.navigationTimer = setTimeout(() => {
+						this.navigationTimer = null
+						if(this.pageUnloaded) {
+							settle(reject, new Error('页面已离开'))
+							return
+						}
+						try {
+							invokeNavigation({
+								success: result => {
+									if(this.pageUnloaded) settle(reject, new Error('页面已离开'))
+									else settle(resolve, result)
+								},
+								fail: error => settle(reject, new Error(error && (error.errMsg || error.message) || '跳转失败'))
+							})
+						} catch(error) {
+							settle(reject, error)
+						}
+					},1500)
+				})
+			},
+			cancelPendingNavigation(error) {
+				if(this.navigationTimer) clearTimeout(this.navigationTimer)
+				this.navigationTimer = null
+				const reject = this.navigationReject
+				this.navigationReject = null
+				if(reject) reject(error || new Error('跳转已取消'))
 			}
 		}
 	}

+ 374 - 4
tests/teamBackgroundFile.test.js

@@ -93,8 +93,21 @@ assert.deepEqual(creationState, {
 	createdTeamId: '',
 	submitting: false,
 	stage: 'idle',
-	completed: false
+	completed: false,
+	teamPayloadSnapshot: '',
+	teamQuestionnaireId: '',
+	publishResult: null
 })
+const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
+	teamName: '团队', enterpriseWebsite: ' https://example.com ', coachId: 7
+})
+const equivalentPayloadSnapshot = rules.createTeamPayloadSnapshot({
+	enterpriseWebsite: 'https://example.com', teamName: '团队'
+})
+assert.equal(firstPayloadSnapshot, equivalentPayloadSnapshot)
+assert.notEqual(firstPayloadSnapshot, rules.createTeamPayloadSnapshot({
+	teamName: '团队', enterpriseWebsite: 'https://changed.example.com'
+}))
 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)
@@ -545,7 +558,9 @@ function readSfcScript(filePath) {
 	return source.slice(start + openingTag.length, end)
 }
 
-async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRules = rules } = {}) {
+async function loadVueComponent(filePath, {
+	http = {}, uni = {}, wx = {}, apiRules = rules, timers = {}
+} = {}) {
 	const context = vm.createContext({
 		uni,
 		wx,
@@ -556,8 +571,10 @@ async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRul
 		Boolean,
 		Object,
 		Array,
-		setInterval,
-		clearInterval
+		setInterval: timers.setInterval || setInterval,
+		clearInterval: timers.clearInterval || clearInterval,
+		setTimeout: timers.setTimeout || setTimeout,
+		clearTimeout: timers.clearTimeout || clearTimeout
 	})
 	const vueStub = new vm.SyntheticModule(['default'], function () {
 		this.setExport('default', {})
@@ -611,6 +628,39 @@ function createDeferred() {
 	return { promise, resolve, reject }
 }
 
+function createFakeTimers() {
+	let nextId = 1
+	const pending = new Map()
+	return {
+		setTimeout(callback) {
+			const id = nextId++
+			pending.set(id, callback)
+			return id
+		},
+		clearTimeout(id) {
+			pending.delete(id)
+		},
+		count() {
+			return pending.size
+		},
+		runNext() {
+			const entry = pending.entries().next().value
+			if (!entry) return false
+			pending.delete(entry[0])
+			entry[1]()
+			return true
+		},
+		runAll() {
+			while (this.runNext()) {}
+		}
+	}
+}
+
+async function flushPromises() {
+	await Promise.resolve()
+	await new Promise(resolve => setImmediate(resolve))
+}
+
 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')
@@ -661,6 +711,97 @@ async function testSessionRefreshFailureKeepsOldTimer() {
 	}
 }
 
+async function testPcSessionCreationIsSingleFlightPerTeam() {
+	const firstRequest = createDeferred()
+	let createCalls = 0
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession() {
+				createCalls += 1
+				return firstRequest.promise
+			}
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, $showToast() {}, $emit() {}
+	})
+	instance.resetTeamContext(12)
+	const first = instance.openPcSession(12)
+	const second = instance.openPcSession(12)
+	assert.equal(createCalls, 1, 'same-team callers must share one session POST')
+	assert.equal(instance.sessionCreating, true)
+	instance.refreshPcSession()
+	assert.equal(createCalls, 1, 'disabled regenerate must not create another POST')
+
+	instance.resetTeamContext(13)
+	assert.equal(instance.sessionCreating, false, 'creating state follows the active team')
+	firstRequest.resolve({
+		code: '123456', uploadUrl: 'https://upload.example/', expiresAt: '2099-01-01 00:00:00'
+	})
+	assert.equal(await first, null)
+	assert.equal(await second, null)
+	assert.equal(instance.session, null, 'a stale context must not display the resolved session')
+	assert.equal(instance.pcDialogVisible, false)
+}
+
+async function testPcSessionSingleFlightSurvivesSameTeamReset() {
+	const request = createDeferred()
+	let createCalls = 0
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession() {
+				createCalls += 1
+				return request.promise
+			}
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, $showToast() {}, $emit() {}
+	})
+	instance.resetTeamContext(21)
+	const staleCaller = instance.openPcSession(21)
+	instance.resetTeamContext(21)
+	assert.equal(instance.sessionCreating, true, 'same-team reset must retain the network lock')
+	const currentCaller = instance.openPcSession(21)
+	assert.equal(createCalls, 1, 'same-team reset must reuse the raw network promise')
+	const rawSession = {
+		code: '654321', uploadUrl: 'https://upload.example/current', expiresAt: '2099-01-01 00:00:00'
+	}
+	request.resolve(rawSession)
+	assert.equal(await staleCaller, null)
+	assert.equal(await currentCaller, rawSession)
+	assert.equal(instance.session, rawSession)
+	assert.equal(instance.pcDialogVisible, true)
+	assert.equal(instance.sessionCreating, false)
+	instance.closePcDialog()
+}
+
+async function testPcSessionFailureClearsSingleFlightForRetry() {
+	let createCalls = 0
+	const component = await loadVueComponent(backgroundComponentPath, {
+		http: {
+			createTeamBackgroundSession() {
+				createCalls += 1
+				if (createCalls === 1) return Promise.reject(new Error('session unavailable'))
+				return Promise.resolve({
+					code: '222222', uploadUrl: 'https://upload.example/retry', expiresAt: '2099-01-01 00:00:00'
+				})
+			}
+		}
+	})
+	const instance = instantiateComponent(component, {
+		teamId: '', editable: true, $showToast() {}, $emit() {}
+	})
+	instance.resetTeamContext(22)
+	await assert.rejects(instance.openPcSession(22), /session unavailable/)
+	assert.equal(instance.sessionCreating, false)
+	const session = await instance.openPcSession(22)
+	assert.equal(createCalls, 2)
+	assert.equal(session.code, '222222')
+	assert.equal(instance.sessionCreating, false)
+	instance.closePcDialog()
+}
+
 async function testDeferredPcFinishWaitsForListRefresh() {
 	const component = await loadVueComponent(backgroundComponentPath, {
 		http: {
@@ -917,6 +1058,228 @@ async function testCreatedTeamResumeAndSubmitLock() {
 	await Promise.all([lockedFirst, lockedSecond])
 }
 
+async function testCreatedTeamNavigationIsAwaitableAndResumable() {
+	const timers = createFakeTimers()
+	let navigationOptions
+	let teamPostCalls = 0
+	let publishCalls = 0
+	let teamPutCalls = 0
+	let flushCalls = 0
+	const toasts = []
+	const uni = {
+		getStorageSync(key) {
+			assert.equal(key, 'userInfo')
+			return JSON.stringify({ id: 7 })
+		},
+		removeStorageSync() {},
+		navigateTo(options) { navigationOptions = options }
+	}
+	const component = await loadVueComponent(createTeamPagePath, { uni, timers })
+	const originalFormat = Date.prototype.Format
+	Date.prototype.Format = () => '2026-07-21 12:00:00'
+	try {
+		const instance = instantiateComponent(component, {
+			next: '1', questionnaireId: 66, type: 'survey', title: '问卷',
+			$api: {
+				post(url, payload) {
+					if (url === '/core/user/team') {
+						teamPostCalls += 1
+						assert.equal(payload.enterpriseWebsite, 'https://old.example')
+						return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
+					}
+					assert.equal(url, '/core/team/questionnaire/publish')
+					assert.deepEqual(Object.keys(payload).sort(), [
+						'answerSetting', 'coachId', 'endTime', 'questionnaireId',
+						'startTime', 'teamId', 'type'
+					].sort())
+					publishCalls += 1
+					return Promise.resolve({ data: { code: 0, data: 321 } })
+				},
+				put(url, payload) {
+					teamPutCalls += 1
+					assert.equal(url, '/core/user/team')
+					assert.equal(payload.id, 88)
+					assert.equal(payload.enterpriseWebsite, 'https://new.example')
+					return Promise.resolve({ data: { code: 0, data: true } })
+				}
+			},
+			$showToast(message) { toasts.push(message) },
+			$showModal: () => Promise.resolve(),
+			$refs: {
+				teamRef: {
+					flushBackgroundFiles() { flushCalls += 1; return Promise.resolve({ success: 0, failed: 0 }) },
+					hasUnresolvedBackgroundFiles: () => false,
+					hasDeferredPcUpload: () => false
+				}
+			}
+		})
+
+		const first = instance.handleConfirm({
+			teamName: '团队', enterpriseWebsite: 'https://old.example'
+		})
+		await flushPromises()
+		assert.equal(teamPostCalls, 1)
+		assert.equal(publishCalls, 1)
+		assert.equal(flushCalls, 1)
+		assert.equal(instance.creationState.teamQuestionnaireId, 321)
+		assert.equal(instance.creationState.completed, false)
+		assert.equal(instance.creationState.submitting, true)
+		assert.equal(timers.count(), 1)
+
+		timers.runNext()
+		await flushPromises()
+		assert.ok(navigationOptions)
+		assert.equal(instance.creationState.completed, false, 'navigation callback must settle first')
+		navigationOptions.fail({ errMsg: 'navigateTo:fail route missing' })
+		await first
+		assert.equal(instance.creationState.completed, false)
+		assert.equal(instance.creationState.submitting, false)
+		assert.equal(instance.creationState.stage, 'navigate')
+		assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
+
+		navigationOptions = null
+		const retry = instance.handleConfirm({
+			teamName: '团队', enterpriseWebsite: 'https://new.example'
+		})
+		await flushPromises()
+		assert.equal(teamPostCalls, 1, 'retry must reuse the created team id')
+		assert.equal(publishCalls, 1, 'retry after publish success must not publish again')
+		assert.equal(flushCalls, 1, 'navigate-stage retry must not repeat file processing')
+		assert.equal(teamPutCalls, 1, 'changed sanitized payload must update the same team')
+		assert.equal(timers.count(), 1)
+		timers.runNext()
+		await flushPromises()
+		assert.ok(navigationOptions)
+		assert.equal(instance.creationState.completed, false)
+		navigationOptions.success({})
+		await retry
+		assert.equal(instance.creationState.completed, true)
+		assert.equal(instance.creationState.submitting, false)
+	} finally {
+		if (originalFormat) Date.prototype.Format = originalFormat
+		else delete Date.prototype.Format
+	}
+}
+
+async function testCreatedTeamUnloadCancelsLateNavigation() {
+	const timers = createFakeTimers()
+	let navigateBackCalls = 0
+	const uni = {
+		navigateBack() { navigateBackCalls += 1 }
+	}
+	const component = await loadVueComponent(createTeamPagePath, { uni, timers })
+	const instance = instantiateComponent(component, {
+		$api: {
+			post: () => Promise.resolve({ data: { code: 0, data: { teamId: 89 } } })
+		},
+		$showToast() {},
+		$showModal: () => Promise.resolve(),
+		getOpenerEventChannel: () => ({ emit() {} }),
+		$refs: {
+			teamRef: {
+				flushBackgroundFiles: () => Promise.resolve({ success: 0, failed: 0 }),
+				hasUnresolvedBackgroundFiles: () => false,
+				hasDeferredPcUpload: () => false
+			}
+		}
+	})
+	const submission = instance.handleConfirm({ teamName: '团队', enterpriseWebsite: '无' })
+	await flushPromises()
+	assert.equal(timers.count(), 1)
+	component.onUnload.call(instance)
+	assert.equal(timers.count(), 0)
+	timers.runAll()
+	await submission
+	assert.equal(navigateBackCalls, 0)
+	assert.equal(instance.creationState.completed, false)
+	assert.equal(instance.creationState.submitting, false)
+}
+
+async function testTeamEditRedirectIsLockedAndResumable() {
+	const timers = createFakeTimers()
+	let redirectOptions
+	let putCalls = 0
+	let navigateBackCalls = 0
+	const toasts = []
+	const uni = {
+		redirectTo(options) { redirectOptions = options },
+		navigateBack() { navigateBackCalls += 1 }
+	}
+	const component = await loadVueComponent(teamEditPagePath, { uni, timers })
+	const instance = instantiateComponent(component, {
+		submitDto: { id: 12, teamName: '团队' },
+		show: true,
+		$api: {
+			put(url, payload) {
+				putCalls += 1
+				assert.equal(url, '/core/user/team')
+				assert.equal(payload.id, 12)
+				return Promise.resolve({ data: { code: 0, data: true } })
+			}
+		},
+		$showToast(message) { toasts.push(message) }
+	})
+
+	const first = instance.editConfirm()
+	await flushPromises()
+	assert.equal(putCalls, 1)
+	assert.equal(instance.saveSucceeded, true)
+	assert.equal(instance.saving, true, 'save lock must cover the redirect delay')
+	assert.equal(timers.count(), 1)
+	instance.editConfirm()
+	instance.requestBack()
+	assert.equal(putCalls, 1, 'confirm during redirect delay must not PUT again')
+	assert.equal(navigateBackCalls, 0, 'back during redirect delay must stay locked')
+	assert.ok(toasts.some(message => String(message).includes('正在保存')))
+
+	timers.runNext()
+	await flushPromises()
+	assert.ok(redirectOptions)
+	assert.equal(instance.saving, true, 'redirect callback must settle before unlock')
+	redirectOptions.fail({ errMsg: 'redirectTo:fail route missing' })
+	await first
+	assert.equal(instance.saving, false)
+	assert.equal(instance.saveSucceeded, true)
+	assert.equal(instance.saveStage, 'navigate')
+
+	redirectOptions = null
+	const retry = instance.editConfirm()
+	await flushPromises()
+	assert.equal(putCalls, 1, 'redirect retry must reuse the successful PUT')
+	assert.equal(instance.saving, true)
+	assert.equal(timers.count(), 1)
+	timers.runNext()
+	await flushPromises()
+	assert.ok(redirectOptions)
+	redirectOptions.success({})
+	await retry
+	assert.equal(putCalls, 1)
+	assert.equal(instance.saving, false)
+}
+
+async function testTeamEditUnloadCancelsLateRedirect() {
+	const timers = createFakeTimers()
+	let redirectCalls = 0
+	const component = await loadVueComponent(teamEditPagePath, {
+		timers,
+		uni: { redirectTo() { redirectCalls += 1 } }
+	})
+	const instance = instantiateComponent(component, {
+		submitDto: { id: 12 },
+		$api: { put: () => Promise.resolve({ data: { code: 0, data: true } }) },
+		$showToast() {}
+	})
+	const saving = instance.editConfirm()
+	await flushPromises()
+	assert.equal(timers.count(), 1)
+	component.onUnload.call(instance)
+	assert.equal(timers.count(), 0)
+	timers.runAll()
+	await saving
+	assert.equal(redirectCalls, 0)
+	assert.equal(instance.saving, false)
+}
+
 async function testCloseAndBackRequirePendingUploadConfirmation() {
 	let modalOptions
 	let navigateBackCalls = 0
@@ -979,11 +1342,18 @@ async function main() {
 	await testDownloadTransport()
 	await testDeferredPcSelectionSurvivesSessionFailure()
 	await testSessionRefreshFailureKeepsOldTimer()
+	await testPcSessionCreationIsSingleFlightPerTeam()
+	await testPcSessionSingleFlightSurvivesSameTeamReset()
+	await testPcSessionFailureClearsSingleFlightForRetry()
 	await testDeferredPcFinishWaitsForListRefresh()
 	await testTeamContextResetAndUploadIsolation()
 	await testFileActionsWaitForCurrentListAndDocumentContext()
 	await testTeamFillFlushesBeforeEmitAndLocks()
 	await testCreatedTeamResumeAndSubmitLock()
+	await testCreatedTeamNavigationIsAwaitableAndResumable()
+	await testCreatedTeamUnloadCancelsLateNavigation()
+	await testTeamEditRedirectIsLockedAndResumable()
+	await testTeamEditUnloadCancelsLateRedirect()
 	await testCloseAndBackRequirePendingUploadConfirmation()
 	console.log('team background transport: PASS')
 	console.log('team background component behavior: PASS')

+ 23 - 1
utils/teamBackgroundFile.js

@@ -93,6 +93,19 @@ function createTeamSubmitPayload(teamInfo) {
 	return payload
 }
 
+function sortPayloadValue(value) {
+	if (Array.isArray(value)) return value.map(sortPayloadValue)
+	if (!value || typeof value !== 'object') return value
+	return Object.keys(value).sort().reduce((result, key) => {
+		result[key] = sortPayloadValue(value[key])
+		return result
+	}, {})
+}
+
+function createTeamPayloadSnapshot(teamInfo) {
+	return JSON.stringify(sortPayloadValue(createTeamSubmitPayload(teamInfo)))
+}
+
 function createEmptyTextPreviewState() {
 	return { visible: false, fileName: '', content: '', truncated: false }
 }
@@ -108,7 +121,15 @@ function createTextPreviewState(result, fallbackFileName) {
 }
 
 function createTeamCreationState() {
-	return { createdTeamId: '', submitting: false, stage: 'idle', completed: false }
+	return {
+		createdTeamId: '',
+		submitting: false,
+		stage: 'idle',
+		completed: false,
+		teamPayloadSnapshot: '',
+		teamQuestionnaireId: '',
+		publishResult: null
+	}
 }
 
 function beginTeamSubmission(state) {
@@ -150,6 +171,7 @@ module.exports = {
 	createBackgroundQueueItem,
 	getUploadableBackgroundFiles,
 	createTeamSubmitPayload,
+	createTeamPayloadSnapshot,
 	createEmptyTextPreviewState,
 	createTextPreviewState,
 	createTeamCreationState,