Преглед на файлове

feat: add team website and background files

Developer преди 3 дни
родител
ревизия
5a05f29fc8

+ 572 - 0
components/CusTeamBackgroundFiles/index.vue

@@ -0,0 +1,572 @@
+<template>
+	<view class="background-files">
+		<view class="background-files-head adfacjb">
+			<view>
+				<view class="background-files-title">团队背景资料</view>
+				<view class="background-files-count">已上传 {{ count }} 个文档</view>
+			</view>
+			<view v-if="editable" class="background-files-add" @click="chooseWechatFiles">从微信会话选择</view>
+		</view>
+
+		<template v-if="editable">
+			<view class="background-files-pc" @click="usePcUpload">使用电脑上传</view>
+
+			<view v-if="files.length" class="file-list">
+				<view v-for="file in files" :key="file.id" class="file-row adfacjb">
+					<view class="file-main">
+						<view class="file-name">{{ file.fileName || file.name || '未命名文件' }}</view>
+						<view class="file-meta">{{ formatFileSize(file.fileSize || file.size) }}</view>
+					</view>
+					<view class="file-actions adfac">
+						<view class="file-action" @click="previewFile(file)">{{ previewingId === file.id ? '打开中' : '查看' }}</view>
+						<view class="file-action danger" @click="removeFile(file)">停用</view>
+					</view>
+				</view>
+			</view>
+
+			<view v-if="queue.length" class="queue-list">
+				<view class="queue-title">本次选择</view>
+				<view v-for="item in queue" :key="item.id" class="queue-row">
+					<view class="queue-line adfacjb">
+						<view class="queue-name">{{ item.name }}</view>
+						<view class="queue-status" :class="item.status">{{ queueStatusText(item) }}</view>
+					</view>
+					<view v-if="item.status === 'uploading'" class="queue-progress">
+						<view class="queue-progress-current" :style="{ width: item.progress + '%' }"></view>
+					</view>
+					<view v-if="item.error" class="queue-error">{{ item.error }}</view>
+					<view v-if="item.status === 'failed' && !item.validationError" class="queue-retry" @click="retryItem(item)">重试</view>
+				</view>
+			</view>
+		</template>
+
+		<view v-if="pcDialogVisible" class="overlay adffcacjc">
+			<view class="pc-dialog">
+				<view class="dialog-title">使用电脑上传</view>
+				<view class="dialog-desc" v-if="remainingSeconds > 0">在电脑打开链接或输入验证码,有效期剩余 {{ remainingText }}</view>
+				<view class="dialog-expired" v-else>本次上传会话已过期,请重新生成</view>
+				<view class="session-expiry">失效时间:{{ session && session.expiresAt || '-' }}</view>
+				<view class="session-code">{{ session && session.code || '------' }}</view>
+				<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>
+				<view class="session-actions adfacjb">
+					<view class="session-button primary" @click="finishDeferredPcUpload">完成并刷新</view>
+					<view class="session-button secondary" @click="skipDeferredPcUpload">暂不上传</view>
+				</view>
+			</view>
+		</view>
+
+		<view v-if="textPreview.visible" class="overlay text-overlay adffcacjc">
+			<view class="text-dialog adffc">
+				<view class="dialog-title text-title">{{ textPreview.fileName }}</view>
+				<view v-if="textPreview.truncated" class="truncated-tip">文件较大,仅展示前 256KB 内容</view>
+				<scroll-view class="text-content" scroll-y>
+					<text selectable>{{ textPreview.content }}</text>
+				</scroll-view>
+				<view class="text-close" @click="closeTextPreview">关闭</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import {
+	createTeamBackgroundSession,
+	disableTeamBackgroundFile,
+	downloadTeamBackgroundFile,
+	listTeamBackgroundFiles,
+	uploadTeamBackgroundFile
+} from '@/http/teamBackgroundFile.js'
+import teamBackgroundRules from '@/utils/teamBackgroundFile.js'
+
+const {
+	createBackgroundQueueItem,
+	createEmptyTextPreviewState,
+	createTextPreviewState,
+	getSessionRemainingSeconds,
+	getUploadableBackgroundFiles
+} = teamBackgroundRules
+
+export default {
+	props: {
+		teamId: { type: [String, Number], default: '' },
+		initialCount: { type: Number, default: 0 },
+		editable: { type: Boolean, default: true }
+	},
+	data() {
+		return {
+			boundTeamId: '',
+			session: null,
+			pcDialogVisible: false,
+			pcUploadDeferred: false,
+			deferredPcResolve: null,
+			files: [],
+			count: this.initialCount || 0,
+			queue: [],
+			remainingSeconds: 0,
+			sessionTimer: null,
+			sessionRequestId: 0,
+			refreshRequestId: 0,
+			previewRequestId: 0,
+			previewingId: '',
+			uploadPromise: null,
+			hasLoadedFiles: false,
+			componentDestroyed: false,
+			textPreview: createEmptyTextPreviewState()
+		}
+	},
+	computed: {
+		remainingText() {
+			const minutes = Math.floor(this.remainingSeconds / 60)
+			const seconds = this.remainingSeconds % 60
+			return `${minutes}分${String(seconds).padStart(2, '0')}秒`
+		}
+	},
+	watch: {
+		teamId: {
+			immediate: true,
+			handler(value) {
+				this.boundTeamId = value || ''
+				this.hasLoadedFiles = false
+				this.refreshRequestId += 1
+				if (value && this.editable) {
+					this.refreshFiles(value).catch(error => this.showError(error, '资料列表加载失败'))
+				}
+			}
+		},
+		initialCount(value) {
+			if (!this.hasLoadedFiles) this.count = Number(value) || 0
+		}
+	},
+	beforeDestroy() {
+		this.componentDestroyed = true
+		this.sessionRequestId += 1
+		this.refreshRequestId += 1
+		this.previewRequestId += 1
+		this.clearSessionTimer()
+		this.closeTextPreview()
+		this.resolveDeferredPcUpload()
+	},
+	methods: {
+		activeTeamId(teamId) {
+			return teamId || this.teamId || this.boundTeamId
+		},
+		showError(error, fallback) {
+			if (this.componentDestroyed) return
+			this.$showToast(error && (error.message || error.msg) || fallback)
+		},
+		queueStatusText(item) {
+			const labels = {
+				pending: '待上传',
+				uploading: `${item.progress || 0}%`,
+				success: '已上传',
+				failed: '失败'
+			}
+			return labels[item.status] || ''
+		},
+		formatFileSize(bytes) {
+			const size = Number(bytes) || 0
+			if (size < 1024) return `${size} B`
+			if (size < 1024 * 1024) return `${Number((size / 1024).toFixed(1))} KB`
+			return `${Number((size / 1024 / 1024).toFixed(1))} MB`
+		},
+		chooseWechatFiles() {
+			if (typeof wx === 'undefined' || typeof wx.chooseMessageFile !== 'function') {
+				this.$showToast('请在微信小程序中选择文件')
+				return
+			}
+			wx.chooseMessageFile({
+				count: 10,
+				type: 'file',
+				success: result => {
+					const selectedAt = Date.now()
+					;(result.tempFiles || []).forEach((file, index) => {
+						this.queue.push(createBackgroundQueueItem(file, `${selectedAt}-${index}-${file.path}`))
+					})
+					const teamId = this.activeTeamId()
+					if (teamId) this.uploadPending(teamId).catch(error => this.showError(error, '上传失败,请重试'))
+				},
+				fail: error => {
+					if (error && !String(error.errMsg || '').includes('cancel')) this.showError(error, '文件选择失败')
+				}
+			})
+		},
+		uploadPending(teamId) {
+			return this.flushPendingFiles(teamId)
+		},
+		async flushPendingFiles(teamId) {
+			const targetTeamId = this.activeTeamId(teamId)
+			if (!targetTeamId) return { success: 0, failed: 0 }
+			this.boundTeamId = targetTeamId
+			if (this.uploadPromise) return this.uploadPromise
+			const upload = this.performPendingUploads(targetTeamId)
+			this.uploadPromise = upload
+			let result
+			try {
+				result = await upload
+			} finally {
+				if (this.uploadPromise === upload) this.uploadPromise = null
+			}
+			if (!this.componentDestroyed && this.queue.some(item => item.status === 'pending')) {
+				const laterResult = await this.flushPendingFiles(targetTeamId)
+				return {
+					success: result.success + laterResult.success,
+					failed: result.failed + laterResult.failed
+				}
+			}
+			return result
+		},
+		async performPendingUploads(teamId) {
+			let success = 0
+			let failed = 0
+			for (const item of getUploadableBackgroundFiles(this.queue)) {
+				if (this.componentDestroyed) break
+				item.status = 'uploading'
+				item.error = ''
+				try {
+					await uploadTeamBackgroundFile(teamId, item.path, progress => {
+						if (!this.componentDestroyed) item.progress = progress
+					})
+					if (this.componentDestroyed) break
+					item.status = 'success'
+					item.progress = 100
+					success += 1
+				} catch (error) {
+					if (this.componentDestroyed) break
+					item.status = 'failed'
+					item.error = error && (error.message || error.msg) || '上传失败,请重试'
+					failed += 1
+				}
+			}
+			if (!this.componentDestroyed) await this.refreshFiles(teamId)
+			return { success, failed }
+		},
+		retryItem(item) {
+			if (item.validationError || item.status === 'uploading') return
+			item.status = 'pending'
+			item.error = ''
+			item.progress = 0
+			const teamId = this.activeTeamId()
+			if (teamId) this.uploadPending(teamId).catch(error => this.showError(error, '上传失败,请重试'))
+		},
+		async refreshFiles(teamId = this.activeTeamId()) {
+			if (!teamId || !this.editable) return []
+			const requestId = ++this.refreshRequestId
+			let files
+			try {
+				files = await listTeamBackgroundFiles(teamId)
+			} catch (error) {
+				if (this.componentDestroyed || requestId !== this.refreshRequestId) return this.files
+				throw error
+			}
+			if (this.componentDestroyed || requestId !== this.refreshRequestId
+				|| String(teamId) !== String(this.activeTeamId())) return this.files
+			this.files = Array.isArray(files) ? files : []
+			this.count = this.files.length
+			this.hasLoadedFiles = true
+			this.$emit('countChange', this.count)
+			return this.files
+		},
+		async previewFile(file) {
+			const teamId = this.activeTeamId()
+			if (!teamId || !file || file.id === undefined || file.id === null) 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 (result && result.mode === 'text') {
+					this.textPreview = createTextPreviewState(result, file.fileName || file.name)
+				}
+			} catch (error) {
+				if (requestId === this.previewRequestId) this.showError(error, '文件打开失败')
+			} finally {
+				if (requestId === this.previewRequestId) this.previewingId = ''
+			}
+		},
+		closeTextPreview() {
+			this.previewRequestId += 1
+			this.previewingId = ''
+			this.textPreview = createEmptyTextPreviewState()
+		},
+		removeFile(file) {
+			const teamId = this.activeTeamId()
+			if (!teamId || !file || file.id === undefined || file.id === null) return
+			uni.showModal({
+				title: '停用资料',
+				content: `确定停用“${file.fileName || file.name || '该文件'}”吗?`,
+				confirmColor: '#199C9C',
+				success: async result => {
+					if (!result.confirm) return
+					try {
+						await disableTeamBackgroundFile(teamId, file.id)
+						await this.refreshFiles(teamId)
+						if (!this.componentDestroyed) this.$showToast('已停用')
+					} catch (error) {
+						this.showError(error, '停用失败,请重试')
+					}
+				}
+			})
+		},
+		usePcUpload() {
+			const teamId = this.activeTeamId()
+			if (!teamId) {
+				this.pcUploadDeferred = true
+				this.$showToast('将先保存团队,再生成电脑上传方式')
+				return
+			}
+			this.pcUploadDeferred = false
+			this.openPcSession(teamId).catch(error => this.showError(error, '上传会话创建失败'))
+		},
+		async openPcSession(teamId) {
+			const requestId = ++this.sessionRequestId
+			this.clearSessionTimer()
+			let session
+			try {
+				session = await createTeamBackgroundSession(teamId)
+			} catch (error) {
+				if (this.componentDestroyed || requestId !== this.sessionRequestId) return null
+				throw error
+			}
+			if (this.componentDestroyed || requestId !== this.sessionRequestId) return null
+			this.boundTeamId = teamId
+			this.session = session
+			this.pcDialogVisible = true
+			this.startSessionTimer()
+			return session
+		},
+		startSessionTimer() {
+			this.clearSessionTimer()
+			this.updateRemainingSeconds()
+			if (this.remainingSeconds === 0) return
+			this.sessionTimer = setInterval(() => this.updateRemainingSeconds(), 1000)
+		},
+		updateRemainingSeconds() {
+			this.remainingSeconds = getSessionRemainingSeconds(this.session && this.session.expiresAt)
+			if (this.remainingSeconds === 0) this.clearSessionTimer()
+		},
+		clearSessionTimer() {
+			if (this.sessionTimer) clearInterval(this.sessionTimer)
+			this.sessionTimer = null
+		},
+		copyUploadUrl() {
+			if (this.remainingSeconds === 0 || !this.session || !this.session.uploadUrl) return
+			uni.setClipboardData({
+				data: this.session.uploadUrl,
+				success: () => this.$showToast('链接已复制'),
+				fail: error => this.showError(error, '复制失败')
+			})
+		},
+		refreshPcSession() {
+			const teamId = this.activeTeamId()
+			if (!teamId) return
+			this.openPcSession(teamId).catch(error => this.showError(error, '上传会话创建失败'))
+		},
+		hasDeferredPcUpload() {
+			return this.pcUploadDeferred
+		},
+		async waitForDeferredPcUpload(teamId) {
+			this.resolveDeferredPcUpload()
+			const session = await this.openPcSession(teamId)
+			if (!session || this.componentDestroyed) return
+			this.pcUploadDeferred = true
+			return new Promise(resolve => {
+				this.deferredPcResolve = resolve
+			})
+		},
+		resolveDeferredPcUpload() {
+			if (this.deferredPcResolve) this.deferredPcResolve()
+			this.deferredPcResolve = null
+			this.pcUploadDeferred = false
+		},
+		closePcDialog() {
+			this.sessionRequestId += 1
+			this.clearSessionTimer()
+			this.pcDialogVisible = false
+			this.session = null
+			this.remainingSeconds = 0
+		},
+		finishDeferredPcUpload() {
+			const teamId = this.activeTeamId()
+			this.closePcDialog()
+			this.resolveDeferredPcUpload()
+			if (teamId) this.refreshFiles(teamId).catch(error => this.showError(error, '资料列表刷新失败'))
+		},
+		skipDeferredPcUpload() {
+			this.closePcDialog()
+			this.resolveDeferredPcUpload()
+		}
+	}
+}
+</script>
+
+<style scoped lang="scss">
+.background-files {
+	background: #ffffff;
+	border-radius: 24rpx;
+	margin-top: 20rpx;
+	padding: 28rpx 24rpx;
+	box-sizing: border-box;
+}
+.background-files-title, .queue-title, .dialog-title {
+	font-size: 30rpx;
+	color: #002846;
+	line-height: 42rpx;
+}
+.background-files-count, .file-meta, .dialog-desc {
+	font-size: 24rpx;
+	color: #95a5b1;
+	line-height: 34rpx;
+	margin-top: 8rpx;
+}
+.background-files-add {
+	padding: 18rpx 22rpx;
+	border-radius: 36rpx;
+	background: linear-gradient(90deg, #33a7a7 0%, #64bbbb 100%);
+	font-size: 24rpx;
+	color: #ffffff;
+}
+.background-files-pc {
+	font-size: 26rpx;
+	color: #199c9c;
+	line-height: 38rpx;
+	text-align: right;
+	margin-top: 20rpx;
+}
+.file-list, .queue-list {
+	margin-top: 24rpx;
+	border-top: 1rpx solid #efefef;
+}
+.file-row {
+	padding: 22rpx 0;
+	border-bottom: 1rpx solid #efefef;
+}
+.file-main { width: calc(100% - 190rpx); }
+.file-name, .queue-name {
+	font-size: 26rpx;
+	color: #667e90;
+	line-height: 36rpx;
+	word-break: break-all;
+}
+.file-action {
+	font-size: 24rpx;
+	color: #199c9c;
+	margin-left: 20rpx;
+}
+.file-action.danger { color: #fd4f66; }
+.queue-title { margin-top: 22rpx; }
+.queue-row {
+	padding: 20rpx 0;
+	border-bottom: 1rpx solid #efefef;
+	position: relative;
+}
+.queue-name { width: calc(100% - 130rpx); }
+.queue-status { font-size: 24rpx; color: #95a5b1; }
+.queue-status.success { color: #199c9c; }
+.queue-status.failed, .queue-error { color: #fd4f66; }
+.queue-progress {
+	height: 8rpx;
+	background: #f0f2f8;
+	border-radius: 4rpx;
+	margin-top: 12rpx;
+	overflow: hidden;
+}
+.queue-progress-current { height: 100%; background: #4db2b2; }
+.queue-error { font-size: 22rpx; line-height: 32rpx; margin-top: 8rpx; }
+.queue-retry { font-size: 24rpx; color: #199c9c; margin-top: 10rpx; }
+.overlay {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	z-index: 1200;
+	background: rgba(0, 0, 0, .55);
+	padding: 40rpx;
+	box-sizing: border-box;
+}
+.pc-dialog, .text-dialog {
+	width: 100%;
+	background: #ffffff;
+	border-radius: 28rpx;
+	padding: 40rpx 30rpx;
+	box-sizing: border-box;
+}
+.dialog-title { font-weight: bold; text-align: center; }
+.dialog-desc, .dialog-expired { text-align: center; margin-top: 24rpx; }
+.dialog-expired { font-size: 24rpx; color: #fd4f66; }
+.session-expiry { font-size: 22rpx; color: #95a5b1; text-align: center; margin-top: 12rpx; }
+.session-code {
+	font-size: 56rpx;
+	font-weight: bold;
+	letter-spacing: 12rpx;
+	color: #002846;
+	text-align: center;
+	margin-top: 30rpx;
+}
+.session-link {
+	font-size: 22rpx;
+	color: #667e90;
+	line-height: 32rpx;
+	word-break: break-all;
+	background: #f7f8fa;
+	padding: 20rpx;
+	border-radius: 16rpx;
+	margin-top: 26rpx;
+}
+.session-actions { margin-top: 26rpx; }
+.session-button {
+	width: calc(50% - 12rpx);
+	height: 72rpx;
+	line-height: 72rpx;
+	border-radius: 36rpx;
+	font-size: 26rpx;
+	text-align: center;
+	box-sizing: border-box;
+}
+.session-button.primary { color: #ffffff; background: #33a7a7; }
+.session-button.secondary { color: #199c9c; border: 1rpx solid #199c9c; }
+.session-button.disabled { color: #b3bfc8; border-color: #b3bfc8; }
+.text-dialog { height: 80vh; }
+.text-title {
+	width: 100%;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+.truncated-tip {
+	font-size: 22rpx;
+	color: #b06a00;
+	line-height: 32rpx;
+	text-align: center;
+	margin-top: 16rpx;
+}
+.text-content {
+	width: 100%;
+	flex: 1;
+	background: #f7f8fa;
+	border-radius: 16rpx;
+	box-sizing: border-box;
+	padding: 24rpx;
+	margin-top: 22rpx;
+	font-size: 26rpx;
+	color: #334b5c;
+	line-height: 40rpx;
+	white-space: pre-wrap;
+	word-break: break-all;
+}
+.text-close {
+	width: 100%;
+	height: 76rpx;
+	line-height: 76rpx;
+	border-radius: 38rpx;
+	background: #33a7a7;
+	color: #ffffff;
+	font-size: 28rpx;
+	text-align: center;
+	margin-top: 24rpx;
+}
+</style>

+ 18 - 10
components/CusTeamInfo/index.vue

@@ -9,11 +9,15 @@
 						<view class="dialog-box-form-item-left">团队名称</view>
 						<view class="dialog-box-form-item-right">{{teamInfo.teamName||'暂无'}}</view>
 					</view>
-					<view class="dialog-box-form-item adfacjb">
-						<view class="dialog-box-form-item-left">所在公司</view>
-						<view class="dialog-box-form-item-right">{{teamInfo.enterpriseName||'暂无'}}</view>
-					</view>
-					<view class="dialog-box-form-item adfacjb">
+					<view class="dialog-box-form-item adfacjb">
+						<view class="dialog-box-form-item-left">所在公司</view>
+						<view class="dialog-box-form-item-right">{{teamInfo.enterpriseName||'暂无'}}</view>
+					</view>
+					<view class="dialog-box-form-item adfacjb">
+						<view class="dialog-box-form-item-left">企业官网</view>
+						<view class="dialog-box-form-item-right">{{teamInfo.enterpriseWebsite||'无'}}</view>
+					</view>
+					<view class="dialog-box-form-item adfacjb">
 						<view class="dialog-box-form-item-left">所属地区</view>
 						<view class="dialog-box-form-item-right">{{teamInfo.address||'暂无'}}</view>
 					</view>
@@ -29,10 +33,14 @@
 						<view class="dialog-box-form-item-left">团队模式类型</view>
 						<view class="dialog-box-form-item-right">{{teamInfo.organizationsName||'暂无'}}</view>
 					</view>
-					<view class="dialog-box-form-item adfacjb">
-						<view class="dialog-box-form-item-left">团队规模</view>
-						<view class="dialog-box-form-item-right">{{teamInfo.scaleName||'暂无'}}</view>
-					</view>
+					<view class="dialog-box-form-item adfacjb">
+						<view class="dialog-box-form-item-left">团队规模</view>
+						<view class="dialog-box-form-item-right">{{teamInfo.scaleName||'暂无'}}</view>
+					</view>
+					<view class="dialog-box-form-item adfacjb">
+						<view class="dialog-box-form-item-left">团队背景资料</view>
+						<view class="dialog-box-form-item-right">{{teamInfo.backgroundFileCount||0}}个文档</view>
+					</view>
 					<!-- <view class="dialog-box-form-item adfacjb">
 						<view class="dialog-box-form-item-left">团队层级</view>
 						<view class="dialog-box-form-item-right">{{teamInfo.hierarchyName||'暂无'}}</view>
@@ -165,4 +173,4 @@
 			}
 		}
 	}
-</style>
+</style>

+ 66 - 34
components/CusTeamInfoFill/index.vue

@@ -7,12 +7,18 @@
 					<u-input v-model="teamInfo.teamName" placeholder="请输入团队名称" placeholder-style="color:#B3BFC8;" border="none" inputAlign="right" fontSize="30rpx" color="#667E90"/>
 				</view>
 			</view>
-			<view class="form-item adfacjb">
-				<view class="form-item-title red">所在公司</view>
-				<view class="form-item-inp">
-					<u-input v-model="teamInfo.enterpriseName" placeholder="请输入公司名称" placeholder-style="color:#B3BFC8;" border="none" inputAlign="right" fontSize="30rpx" color="#667E90"/>
-				</view>
-			</view>
+			<view class="form-item adfacjb">
+				<view class="form-item-title red">所在公司</view>
+				<view class="form-item-inp">
+					<u-input v-model="teamInfo.enterpriseName" placeholder="请输入公司名称" placeholder-style="color:#B3BFC8;" border="none" inputAlign="right" fontSize="30rpx" color="#667E90"/>
+				</view>
+			</view>
+			<view class="form-item adfacjb">
+				<view class="form-item-title red">企业官网</view>
+				<view class="form-item-inp">
+					<u-input v-model="teamInfo.enterpriseWebsite" placeholder="无官网请填写“无”" placeholder-style="color:#B3BFC8;" border="none" inputAlign="right" fontSize="30rpx" color="#667E90" />
+				</view>
+			</view>
 			<view class="form-item adfacjb">
 				<view class="form-item-title red">所属地区</view>
 				<view class="form-item-inp adfac" @click="pickerShow('areaShow')">
@@ -60,10 +66,11 @@
 			<view class="box-title" :class="{'red':isqtype}">团队简介</view>
 			<view class="box-textarea">
 				<u-textarea border="none" height="320rpx" maxlength="-1" style="color: #667E90;padding: 0;" :placeholderStyle="{'color':'#B3BFC8','font-size':'30rpx','line-height':'40rpx'}" 
-				v-model="teamInfo.brief" placeholder="示例:这是一个为公司ERP系统升级而组建的36人团队,成员由公司副总经理、各部门总监以及各部门核心骨干组成。团队成员工作地点分布在香港、上海、苏州、深圳和东莞等地。团队成员年龄在35-50岁之间,男女比例比较均衡,大部份成员在该公司同事超过十年。"></u-textarea>
-			</view>
-		</view>
-		<view class="zt_btn" @click="handleConfirm">{{confirmText}}</view>
+				v-model="teamInfo.brief" placeholder="示例:这是一个为公司ERP系统升级而组建的36人团队,成员由公司副总经理、各部门总监以及各部门核心骨干组成。团队成员工作地点分布在香港、上海、苏州、深圳和东莞等地。团队成员年龄在35-50岁之间,男女比例比较均衡,大部份成员在该公司同事超过十年。"></u-textarea>
+			</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="dialog" v-if="areaShow">
 			<view class="dialog-box">
 				<cus-province-city-area @cancel="areaShow=false" @confirm="areaConfirm"></cus-province-city-area>
@@ -74,28 +81,32 @@
 	</view>
 </template>
 
-<script>
-	import CusSelect from '@/components/CusSelect/index.vue'
-	import CusProvinceCityArea from '@/components/CusProvinceCityArea/index.vue'
-import props from '../../uni_modules/uview-ui/libs/config/props'
-	export default {
-		components:{ CusSelect, CusProvinceCityArea },
-		props:{
+<script>
+	import CusSelect from '@/components/CusSelect/index.vue'
+	import CusProvinceCityArea from '@/components/CusProvinceCityArea/index.vue'
+	import CusTeamBackgroundFiles from '@/components/CusTeamBackgroundFiles/index.vue'
+	import teamBackgroundRules from '@/utils/teamBackgroundFile.js'
+	const { createTeamSubmitPayload, validateEnterpriseWebsite } = teamBackgroundRules
+	export default {
+		components:{ CusSelect, CusProvinceCityArea, CusTeamBackgroundFiles },
+		props:{
 			confirmText:{
 				typeof:String,
 				default:'下一步'
 			},
-			qtype:{
-				typeof:Boolean,
-				default:false
-			}
+			qtype:{
+				typeof:Boolean,
+				default:false
+			},
+			teamId: { type: [String, Number], default: '' }
 		},
 		options: { styleIsolation: 'shared' },
 		data(){
 			return {
-				teamInfo:{
-					teamName:'',
-					enterpriseName:'',
+				teamInfo:{
+					teamName:'',
+					enterpriseName:'',
+					enterpriseWebsite:'',
 					provinceId:'',
 					cityId:'',
 					districtId:'',
@@ -104,7 +115,8 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 					orgIds:[],
 					scale:'',
 					hierarchy:'',
-					brief:'',
+					brief:'',
+					backgroundFileCount:0
 				},
 				areaShow:false,
 				areaText:'请选择',
@@ -123,7 +135,19 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 		mounted() {
 			this.isqtype = this.$props.qtype||false;
 		},
-		methods:{
+		methods:{
+			backgroundCountChange(count){
+				this.teamInfo.backgroundFileCount = count;
+			},
+			flushBackgroundFiles(teamId) {
+				return this.$refs.backgroundRef.flushPendingFiles(teamId)
+			},
+			hasDeferredPcUpload() {
+				return this.$refs.backgroundRef.hasDeferredPcUpload()
+			},
+			waitForDeferredPcUpload(teamId) {
+				return this.$refs.backgroundRef.waitForDeferredPcUpload(teamId)
+			},
 			getTeamScaleData(){
 				this.$api.get('/getListByType/team_scale').then(({data:res})=>{
 					if(res.code!==0) return this.$showToast(res.msg)
@@ -136,8 +160,12 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 					this.teamLevelData = res.data.map(d=>({name:d.dictLabel,id:d.dictValue}))
 				})
 			},
-			setTeamInfo(info){
-				this.teamInfo = info;
+			setTeamInfo(info){
+				this.teamInfo = Object.assign({
+					teamName:'', enterpriseName:'', enterpriseWebsite:'', provinceId:'', cityId:'',
+					districtId:'', industryId:'', functionIds:[], orgIds:[], scale:'',
+					hierarchy:'', brief:'', backgroundFileCount:0
+				}, info || {});
 			},
 			confirm(e) {
 				const { value } = e;
@@ -198,10 +226,14 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 				this[key2] = e.name;
 				this[key3] = false;
 			},
-			handleConfirm(){
-				if(!this.teamInfo.teamName) return this.$showToast('请输入团队名称')
-				if(!this.teamInfo.enterpriseName) return this.$showToast('请输入公司名称')
-				if(!this.teamInfo.districtId) return this.$showToast('请选择所属地区')
+			handleConfirm(){
+				if(!this.teamInfo.teamName) return this.$showToast('请输入团队名称')
+				if(!this.teamInfo.enterpriseName) return this.$showToast('请输入公司名称')
+				const enterpriseWebsite = String(this.teamInfo.enterpriseWebsite || '').trim()
+				const websiteError = validateEnterpriseWebsite(enterpriseWebsite)
+				if(websiteError) return this.$showToast(websiteError)
+				this.teamInfo.enterpriseWebsite = enterpriseWebsite
+				if(!this.teamInfo.districtId) return this.$showToast('请选择所属地区')
 				if(!this.teamInfo.industryId) return this.$showToast('请选择所属行业')
 				if(this.isqtype){
 					if(this.teamInfo.functionIds.length===0) return this.$showToast('请选择团队职能类型')
@@ -211,7 +243,7 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 					if(!this.teamInfo.brief) return this.$showToast('请输入团队简介')
 				}
 				
-				this.$emit('handleConfirm',this.teamInfo)
+				this.$emit('handleConfirm',createTeamSubmitPayload(this.teamInfo))
 			},
 			areaConfirm(e){
 				const { provinceId, cityId,districtId,provinceName,cityName,districtName } = e
@@ -329,4 +361,4 @@ import props from '../../uni_modules/uview-ui/libs/config/props'
 			z-index: 1000;
 		}
 	}
-</style>
+</style>

+ 1 - 2
pagesHome/components/createList.vue

@@ -73,7 +73,7 @@
 				<view class="dialog-box-title">团队信息</view>
 				<image class="dialog-box-close" :src="imgBase + 'remind_close.png'" @click="teamInfoShow = false"></image>
 				<view class="dialog-box-teaminfo">
-					<cus-team-info-fill ref="teamRef" confirmText="保存" @handleConfirm="handleConfirm"></cus-team-info-fill>
+					<cus-team-info-fill ref="teamRef" :teamId="dto && dto.teamId" confirmText="保存" @handleConfirm="handleConfirm"></cus-team-info-fill>
 				</view>
 			</view>
 		</view>
@@ -272,7 +272,6 @@ export default {
 			})
 		},
 		handleConfirm(data){
-			data.coachId = JSON.parse(uni.getStorageSync('userInfo')).id;
 			this.$api.put('/core/user/team',data).then(({data:res})=>{
 				if(res.code!==0) return this.$showToast(res.msg)
 				this.$showToast('保存成功')

+ 33 - 19
pagesMy/team.vue

@@ -6,10 +6,18 @@
 			<view class="list-item" v-for="(item,index) in list" :key="item.id">
 				<image class="list-item-icon" :src="imgBase+'team_icon.png'"></image>
 				<image class="list-item-edit" :src="imgBase+'team_edit.png'" @click="handleEdit(item)"></image>
-				<view class="list-item-top">
-					<view class="list-item-top-title">{{item.teamName}}</view>
-					<view class="list-item-top-pre adf" style="margin-top: 36rpx;">
-						<view class="list-item-top-pre-left">所属地区:</view>
+				<view class="list-item-top">
+					<view class="list-item-top-title">{{item.teamName}}</view>
+					<view class="list-item-top-pre adf" style="margin-top: 36rpx;">
+						<view class="list-item-top-pre-left">所在公司:</view>
+						<view class="list-item-top-pre-right">{{item.enterpriseName||''}}</view>
+					</view>
+					<view class="list-item-top-pre adf">
+						<view class="list-item-top-pre-left">企业官网:</view>
+						<view class="list-item-top-pre-right">{{item.enterpriseWebsite||'无'}}</view>
+					</view>
+					<view class="list-item-top-pre adf">
+						<view class="list-item-top-pre-left">所属地区:</view>
 						<view class="list-item-top-pre-right">{{item.provinceName+item.cityName}}</view>
 					</view>
 					<view class="list-item-top-pre adf">
@@ -24,10 +32,14 @@
 						<view class="list-item-top-pre-left">团队模式类型:</view>
 						<view class="list-item-top-pre-right">{{item.orgNames||''}}</view>
 					</view>
-					<view class="list-item-top-pre adf">
-						<view class="list-item-top-pre-left">团队规模:</view>
-						<view class="list-item-top-pre-right">{{item.scaleName||''}}</view>
-					</view>
+					<view class="list-item-top-pre adf">
+						<view class="list-item-top-pre-left">团队规模:</view>
+						<view class="list-item-top-pre-right">{{item.scaleName||''}}</view>
+					</view>
+					<view class="list-item-top-pre adf">
+						<view class="list-item-top-pre-left">团队背景资料:</view>
+						<view class="list-item-top-pre-right background-link" @click.stop="handleEdit(item)">{{item.backgroundFileCount||0}}个背景资料文档</view>
+					</view>
 					<!-- <view class="list-item-top-pre adf">
 						<view class="list-item-top-pre-left">团队层级:</view>
 						<view class="list-item-top-pre-right">{{item.hierarchyName||''}}</view>
@@ -58,10 +70,9 @@
 		data(){
 			return {
 				list:[],
-				query:{
-					page:1,
-					limit:10,
-					coachId:''
+				query:{
+					page:1,
+					limit:10
 				},
 				isPerson:true,
 				isOver:false,
@@ -69,9 +80,8 @@
 				hierarchyMap:new Map()
 			}
 		},
-		async onLoad() {
-			this.query.coachId = uni.getStorageSync('userInfo')&&JSON.parse(uni.getStorageSync('userInfo')).id||'';
-			await this.getTeamScaleData();
+		async onLoad() {
+			await this.getTeamScaleData();
 			await this.getTeamHierarchyData();
 			this.getList()
 		},
@@ -193,14 +203,18 @@
 							color: #6B7280;
 							line-height: 32rpx;
 						}
-						&-right{
+						&-right{
 							width: calc(100% - 202rpx);
 							font-family: PingFangSC, PingFang SC;
 							font-weight: 400;
 							font-size: 24rpx;
 							color: #6B7280;
-							line-height: 32rpx;
-						}
+							line-height: 32rpx;
+							&.background-link{
+								color: #199C9C;
+								text-decoration: underline;
+							}
+						}
 					}
 				}
 				&-bottom{
@@ -246,4 +260,4 @@
 			flex: 1;
 		}
 	}
-</style>
+</style>

+ 17 - 9
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" @handleConfirm="handleConfirm" :confirmText="confirmText"></cus-team-info-fill>
+		<cus-team-info-fill ref="teamRef" :teamId="id" @handleConfirm="handleConfirm" :confirmText="confirmText"></cus-team-info-fill>
 		<view class="dialog adffcacjc" v-if="show">
 			<view class="dbox">
 				<view class="dbox-title">温馨提示</view>
@@ -30,12 +30,21 @@
 				show:false
 			}
 		},
-		onLoad(options) {
+		onLoad(options) {
 			this.id = options.id;
 			this.scaleName = options.scaleName;
 			this.hierarchyName = options.hierarchyName;
-			this.getDetail()
-		},
+			this.getDetail()
+		},
+		onShow() {
+			if (!this.id) return
+			this.$nextTick(() => {
+				const background = this.$refs.teamRef && this.$refs.teamRef.$refs.backgroundRef
+				if (background) background.refreshFiles(this.id).catch(error => {
+					this.$showToast(error && (error.message || error.msg) || '资料列表刷新失败')
+				})
+			})
+		},
 		methods:{
 			getDetail(){
 				this.$api.get(`/core/user/team/${this.id}`).then(({data:res})=>{
@@ -51,10 +60,9 @@
 					this.$refs.teamRef.teamScaleText = this.scaleName;
 					this.$refs.teamRef.teamLevelText = this.hierarchyName;
 				})
-			},
-			handleConfirm(data){
-				data.coachId = JSON.parse(uni.getStorageSync('userInfo')).id;
-				this.submitDto = data;
+			},
+			handleConfirm(data){
+				this.submitDto = data;
 				this.show = true;
 			},
 			editConfirm(){
@@ -119,4 +127,4 @@
 			}
 		}
 	}
-</style>
+</style>

+ 50 - 36
pagesPublish/fillTeamInfo.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 @handleConfirm="handleConfirm" :confirmText="confirmText" :qtype="qtype"></cus-team-info-fill>
+		<cus-team-info-fill ref="teamRef" @handleConfirm="handleConfirm" :confirmText="confirmText" :qtype="qtype"></cus-team-info-fill>
 	</view>
 </template>
 
@@ -28,40 +28,54 @@
 			this.next = options.next;
 			if(this.next) this.confirmText = '下一步';
 		},
-		methods:{
-			handleConfirm(team){
-				team.questionnaireId = this.questionnaireId;
-				team.coachId = JSON.parse(uni.getStorageSync('userInfo')).id;
-				this.$api.post('/core/user/team',team).then(({data:res})=>{
-					if(res.code!==0) return this.$showToast(res.msg)
-					if(this.next){
-						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'),
-							endTime:new Date(new Date().setDate(new Date().getDate()+30)).Format('yyyy-MM-dd hh:mm:ss'),
-							questionnaireId:this.questionnaireId,
-							teamId:res.data.teamId,
-							type:1
-						}).then(({data:res2})=>{
-							if(res2.code!==0) return this.$showToast(res2.msg)
-							this.$showToast('保存成功,即将填写问卷')
-							setTimeout(()=>{
-								uni.removeStorageSync('newUser')
-								uni.navigateTo({
-									url:'/pagesPublish/questionnaireFill?teamQuestionnaireId='+res2.data+'&teamId='+res.data.teamId+'&type='+this.type+'&title='+this.title
-								})	
-							},1500)	
-						})	
-					}else{
-						this.$showToast('团队新增成功')
-						setTimeout(()=>{
-							this.getOpenerEventChannel().emit('saveTeamInfo')
-							uni.navigateBack()
-						},1500)
-					} 
-				})	
-			}
+		methods:{
+			async handleConfirm(team){
+				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('团队保存失败,请重试')
+					const uploadResult = await this.$refs.teamRef.flushBackgroundFiles(teamId)
+					if(uploadResult.failed > 0){
+						await this.$showModal(`${uploadResult.failed}个背景资料上传失败,可重试后继续`)
+					}
+					if(this.$refs.teamRef.hasDeferredPcUpload()){
+						await this.$refs.teamRef.waitForDeferredPcUpload(teamId)
+					}
+					await this.continueAfterTeamCreated(teamId)
+				} catch(error) {
+					this.$showToast(error && (error.message || error.msg) || '团队保存失败,请重试')
+				}
+			},
+			async continueAfterTeamCreated(teamId) {
+				if(this.next){
+					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'),
+						endTime:new Date(new Date().setDate(new Date().getDate()+30)).Format('yyyy-MM-dd hh:mm:ss'),
+						questionnaireId:this.questionnaireId,
+						teamId,
+						type:1
+					})
+					const result = response.data
+					if(result.code!==0) return this.$showToast(result.msg)
+					this.$showToast('保存成功,即将填写问卷')
+					setTimeout(()=>{
+						uni.removeStorageSync('newUser')
+						uni.navigateTo({
+							url:`/pagesPublish/questionnaireFill?teamQuestionnaireId=${result.data}&teamId=${teamId}&type=${this.type}&title=${this.title}`
+						})
+					},1500)
+					return
+				}
+				this.$showToast('团队新增成功')
+				setTimeout(()=>{
+					this.getOpenerEventChannel().emit('saveTeamInfo')
+					uni.navigateBack()
+				},1500)
+			}
 		}
 	}
 </script>
@@ -70,4 +84,4 @@
 	.default_page{
 		box-sizing: border-box;
 	}
-</style>
+</style>

+ 72 - 0
tests/teamBackgroundFile.test.js

@@ -16,6 +16,78 @@ assert.equal(rules.validateBackgroundFile({ name: `${'a'.repeat(251)}.pdf`, size
 assert.match(rules.validateBackgroundFile({ name: `${'a'.repeat(252)}.pdf`, size: 1 }), /255/)
 assert.match(rules.validateBackgroundFile({ name: 'empty.pdf', size: 0 }), /非空/)
 
+const backendExpiry = '2026-07-21 15:30:00'
+const explicitOffsetExpiry = '2026-07-21T15:30:00+08:00'
+const utcExpiry = '2026-07-21T07:30:00.250Z'
+assert.equal(rules.parseSessionExpiresAt(backendExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
+assert.equal(rules.parseSessionExpiresAt(explicitOffsetExpiry), Date.UTC(2026, 6, 21, 7, 30, 0))
+assert.equal(rules.parseSessionExpiresAt(utcExpiry), Date.UTC(2026, 6, 21, 7, 30, 0, 250))
+assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 29, 59, 1)), 1)
+assert.equal(rules.getSessionRemainingSeconds(backendExpiry, Date.UTC(2026, 6, 21, 7, 30, 1)), 0)
+assert.equal(rules.getSessionRemainingSeconds('2026-07-21T15:30:00', 0), 0)
+assert.equal(rules.getSessionRemainingSeconds('not-a-date', 0), 0)
+
+const validQueueItem = rules.createBackgroundQueueItem(
+	{ name: 'brief.pdf', path: '/tmp/brief.pdf', size: 1 },
+	'valid-file'
+)
+const invalidQueueItem = rules.createBackgroundQueueItem(
+	{ name: 'archive.zip', path: '/tmp/archive.zip', size: 1 },
+	'invalid-file'
+)
+assert.equal(validQueueItem.status, 'pending')
+assert.equal(validQueueItem.progress, 0)
+assert.equal(validQueueItem.validationError, '')
+assert.equal(invalidQueueItem.status, 'failed')
+assert.match(invalidQueueItem.validationError, /类型/)
+validQueueItem.status = 'success'
+const uploadFailure = {
+	id: 'upload-failure',
+	status: 'failed',
+	validationError: '',
+	error: '上传失败'
+}
+assert.deepEqual(
+	rules.getUploadableBackgroundFiles([validQueueItem, invalidQueueItem, uploadFailure]),
+	[uploadFailure]
+)
+
+const originalTeam = {
+	id: 12,
+	teamName: '研发团队',
+	enterpriseWebsite: ' https://example.com/team ',
+	coachId: 3,
+	uploaderId: 4,
+	source: 'WECHAT'
+}
+const teamSubmitPayload = rules.createTeamSubmitPayload(originalTeam)
+assert.equal(teamSubmitPayload.id, 12)
+assert.equal(teamSubmitPayload.enterpriseWebsite, 'https://example.com/team')
+assert.equal(Object.hasOwn(teamSubmitPayload, 'coachId'), false)
+assert.equal(Object.hasOwn(teamSubmitPayload, 'uploaderId'), false)
+assert.equal(Object.hasOwn(teamSubmitPayload, 'source'), false)
+assert.equal(originalTeam.coachId, 3, 'payload creation must not mutate the displayed detail')
+
+const textPreviewState = rules.createTextPreviewState({
+	mode: 'text',
+	fileName: '背景说明.md',
+	content: '# 团队背景',
+	truncated: true
+}, 'fallback.md')
+assert.deepEqual(textPreviewState, {
+	visible: true,
+	fileName: '背景说明.md',
+	content: '# 团队背景',
+	truncated: true
+})
+assert.deepEqual(rules.createEmptyTextPreviewState(), {
+	visible: false,
+	fileName: '',
+	content: '',
+	truncated: false
+})
+assert.notEqual(rules.createEmptyTextPreviewState(), rules.createEmptyTextPreviewState())
+
 console.log('team background rules: PASS')
 
 const transportPath = path.join(__dirname, '../http/teamBackgroundFile.js')

+ 97 - 1
utils/teamBackgroundFile.js

@@ -1,5 +1,7 @@
 const ALLOWED_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv']
 const MAX_FILE_SIZE = 50 * 1024 * 1024
+const BACKEND_TIMEZONE_OFFSET_MINUTES = 8 * 60
+const SESSION_EXPIRES_AT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})([ T])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:(Z)|([+-])(\d{2}):?(\d{2}))?$/
 
 function validateEnterpriseWebsite(value) {
 	const normalized = String(value || '').trim()
@@ -18,9 +20,103 @@ function validateBackgroundFile(file) {
 	return ''
 }
 
+function parseSessionExpiresAt(value) {
+	const matched = SESSION_EXPIRES_AT_PATTERN.exec(String(value || '').trim())
+	if (!matched) return NaN
+	const year = Number(matched[1])
+	const month = Number(matched[2])
+	const day = Number(matched[3])
+	const separator = matched[4]
+	const hour = Number(matched[5])
+	const minute = Number(matched[6])
+	const second = Number(matched[7])
+	const millisecond = Number((matched[8] || '').padEnd(3, '0'))
+	if (year < 1000 || month < 1 || month > 12 || day < 1 || day > 31
+		|| hour > 23 || minute > 59 || second > 59) return NaN
+
+	const hasExplicitTimezone = Boolean(matched[9] || matched[10])
+	if (separator === 'T' && !hasExplicitTimezone) return NaN
+	if (separator === ' ' && hasExplicitTimezone) return NaN
+
+	const localWallClock = Date.UTC(year, month - 1, day, hour, minute, second, millisecond)
+	const normalized = new Date(localWallClock)
+	if (normalized.getUTCFullYear() !== year || normalized.getUTCMonth() !== month - 1
+		|| normalized.getUTCDate() !== day || normalized.getUTCHours() !== hour
+		|| normalized.getUTCMinutes() !== minute || normalized.getUTCSeconds() !== second) return NaN
+
+	let offsetMinutes = BACKEND_TIMEZONE_OFFSET_MINUTES
+	if (matched[9] === 'Z') {
+		offsetMinutes = 0
+	} else if (matched[10]) {
+		const offsetHour = Number(matched[11])
+		const offsetMinute = Number(matched[12])
+		if (offsetHour > 23 || offsetMinute > 59) return NaN
+		offsetMinutes = (offsetHour * 60 + offsetMinute) * (matched[10] === '+' ? 1 : -1)
+	}
+	return localWallClock - offsetMinutes * 60 * 1000
+}
+
+function getSessionRemainingSeconds(expiresAt, now = Date.now()) {
+	const expiresAtTime = parseSessionExpiresAt(expiresAt)
+	if (!Number.isFinite(expiresAtTime)) return 0
+	return Math.max(0, Math.ceil((expiresAtTime - now) / 1000))
+}
+
+function createBackgroundQueueItem(file, id) {
+	const selectedFile = file || {}
+	const validationError = validateBackgroundFile(selectedFile)
+	return {
+		id,
+		name: selectedFile.name || '',
+		path: selectedFile.path || '',
+		size: selectedFile.size || 0,
+		progress: 0,
+		status: validationError ? 'failed' : 'pending',
+		error: validationError,
+		validationError
+	}
+}
+
+function getUploadableBackgroundFiles(queue) {
+	return Array.isArray(queue)
+		? queue.filter(file => file.status === 'pending'
+			|| (file.status === 'failed' && !file.validationError))
+		: []
+}
+
+function createTeamSubmitPayload(teamInfo) {
+	const payload = Object.assign({}, teamInfo || {})
+	payload.enterpriseWebsite = String(payload.enterpriseWebsite || '').trim()
+	delete payload.source
+	delete payload.coachId
+	delete payload.uploaderId
+	return payload
+}
+
+function createEmptyTextPreviewState() {
+	return { visible: false, fileName: '', content: '', truncated: false }
+}
+
+function createTextPreviewState(result, fallbackFileName) {
+	const preview = result || {}
+	return {
+		visible: true,
+		fileName: preview.fileName || fallbackFileName || '文本预览',
+		content: String(preview.content === undefined || preview.content === null ? '' : preview.content),
+		truncated: Boolean(preview.truncated)
+	}
+}
+
 module.exports = {
 	ALLOWED_EXTENSIONS,
 	MAX_FILE_SIZE,
 	validateEnterpriseWebsite,
-	validateBackgroundFile
+	validateBackgroundFile,
+	parseSessionExpiresAt,
+	getSessionRemainingSeconds,
+	createBackgroundQueueItem,
+	getUploadableBackgroundFiles,
+	createTeamSubmitPayload,
+	createEmptyTextPreviewState,
+	createTextPreviewState
 }