perf(task): 移动端打开任务详情时先播放打开动画再挂载内容,挂载前显示任务头部、标题与属性骨架占位

This commit is contained in:
kuaifan 2026-10-28 13:03:01 +00:00
parent 2556c53fbc
commit 9d07f88e8c
2 changed files with 205 additions and 3 deletions

View File

@ -8,7 +8,73 @@
:fullscreen="windowPortrait"
:beforeClose="onBeforeClose"
class-name="common-task-modal">
<TaskDetail ref="taskDetail" :task-id="taskId" :open-task="taskData" modalMode/>
<TaskDetail v-if="contentReady" ref="taskDetail" :task-id="taskId" :open-task="taskData" modalMode/>
<!--内容挂载前的占位(头部结构与 TaskDetail 一致 + 骨架),避免打开时整页空白-->
<div v-else-if="show" class="task-detail task-detail-placeholder" :class="{completed: taskData.complete_at}">
<div class="task-info">
<div class="head">
<div class="task-menu-icon icon">
<Icon v-if="taskData.complete_at" class="completed" type="md-checkmark-circle"/>
<Icon v-else class="uncomplete" type="md-radio-button-off"/>
</div>
<div
v-if="taskData.flow_item_name"
class="flow"
:style="$A.generateColorVarStyle(taskData.flow_item_color, [10], 'flow-item-custom-color')">
<span :class="taskData.flow_item_status">{{taskData.flow_item_name}}</span>
</div>
<div v-if="taskData.archived_at" class="flow">
<span class="archived">{{$L('已归档')}}</span>
</div>
<div class="nav">
<p v-if="projectName"><span>{{projectName}}</span></p>
<p v-if="columnName"><span>{{columnName}}</span></p>
<p v-if="taskData.id"><span>{{taskData.id}}</span></p>
</div>
<div class="function">
<div class="menu">
<div class="task-menu-icon">
<Icon type="ios-more"/>
</div>
</div>
</div>
</div>
<div class="scroller">
<div class="title">
<div class="placeholder-title">{{taskData.name}}</div>
</div>
<div class="placeholder-skeleton">
<div class="skeleton-desc">
<div class="skeleton-line" style="width:92%"></div>
<div class="skeleton-line" style="width:58%"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-label" style="width:56px"></div>
<div class="skeleton-tag"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-label" style="width:44px"></div>
<div class="skeleton-avatar"></div>
</div>
<div class="skeleton-row">
<div class="skeleton-label" style="width:64px"></div>
<div class="skeleton-avatar"></div>
<div class="skeleton-avatar"></div>
</div>
<div class="skeleton-row multi">
<div class="skeleton-label" style="width:58px"></div>
<div class="skeleton-values">
<div class="skeleton-line" style="width:180px"></div>
<div class="skeleton-line" style="width:84px"></div>
</div>
</div>
<div class="skeleton-add">
<div class="skeleton-line" style="width:62%"></div>
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>
@ -21,14 +87,44 @@ export default {
name: "TaskModal",
components: {TaskDetail},
data() {
return {
contentReady: false,
}
},
computed: {
...mapState(['taskId']),
...mapState(['taskId', 'cacheProjects', 'cacheColumns']),
...mapGetters(['taskData']),
show() {
return this.taskId > 0
},
projectName() {
const {project_id, project_name} = this.taskData
if (!project_id) {
return ''
}
if (project_name) {
return project_name
}
const project = this.cacheProjects.find(({id}) => id == project_id)
return project ? project.name : ''
},
columnName() {
const {column_id, column_name} = this.taskData
if (!column_id) {
return ''
}
if (column_name) {
return column_name
}
const column = this.cacheColumns.find(({id}) => id == column_id)
return column ? column.name : ''
},
styles() {
return this.windowPortrait ? {
width: '100%',
@ -42,6 +138,17 @@ export default {
watch: {
show(v) {
$A.eeuiAppSetScrollDisabled(v && this.windowPortrait)
if (v) {
if (!this.windowPortrait) {
this.contentReady = true
return
}
// 任务详情渲染较重,等打开动画开始(交由合成线程执行)后再挂载,避免动画首帧被阻塞
this.contentReady = false
requestAnimationFrame(_ => requestAnimationFrame(_ => {
this.contentReady = this.show
}))
}
}
},
@ -61,7 +168,7 @@ export default {
},
checkUpdate() {
if (this.show) {
this.$refs.taskDetail.checkUpdate(true);
this.$refs.taskDetail?.checkUpdate(true);
return true;
}
},

View File

@ -1187,3 +1187,98 @@ body.window-portrait {
}
}
}
// 任务详情挂载前的占位与骨架(见 TaskModal.vue)
.task-detail-placeholder {
.task-info {
.scroller {
flex: 1;
padding: 0 5px;
.title {
.placeholder-title {
font-weight: 500;
font-size: 24px;
padding: 5px 1px;
line-height: 1.4;
color: #606266;
word-break: break-all;
}
}
.placeholder-skeleton {
// 尺寸与 TaskDetail 真实布局对齐(描述每行 24px、属性行 36px + 10px 间距),加载完成时不跳动
padding: 10px 1px 0;
animation: task-skeleton-animation 1.4s ease-in-out infinite;
.skeleton-line,
.skeleton-label,
.skeleton-tag,
.skeleton-avatar {
flex-shrink: 0;
background-color: #f0f0f0;
}
.skeleton-line,
.skeleton-label {
height: 12px;
border-radius: 6px;
}
.skeleton-desc {
// 每行占 24px,与描述行高一致
margin-bottom: 12px;
.skeleton-line {
margin: 6px 0;
}
}
.skeleton-add {
display: flex;
align-items: center;
height: 38px;
margin-top: 12px;
}
.skeleton-row {
display: flex;
align-items: center;
height: 36px;
padding-left: 107px;
position: relative;
& + .skeleton-row {
margin-top: 10px;
}
.skeleton-label {
position: absolute;
top: 12px;
left: 0;
}
.skeleton-tag {
width: 72px;
height: 22px;
border-radius: 4px;
}
.skeleton-avatar {
width: 26px;
height: 26px;
margin-right: 6px;
border-radius: 50%;
}
&.multi {
height: 54px;
align-items: flex-start;
.skeleton-values {
padding-top: 11px;
.skeleton-line + .skeleton-line {
margin-top: 12px;
}
}
}
}
}
}
}
}
@keyframes task-skeleton-animation {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}