uni-app——6种状态、3个技术难点、1套方案:前端状态驱动UI完整指南
uni-app——6种状态、3个技术难点、1套方案:前端状态驱动UI完整指南
·
一个看似简单的"状态展示"问题,实际涉及倒计时精度、状态颜色映射、权限按钮显示、定时器管理等多个技术点。本文记录如何系统性地解决这类"状态驱动UI"的综合性问题。
一、问题背景
1.1 问题现象
在某个业务列表模块的"待开始"状态下,存在以下问题:
列表页问题:
| 序号 | 问题 | 影响 |
|---|---|---|
| 1 | 倒计时只显示到分钟 | 用户无法感知精确剩余时间 |
| 2 | 部分用户看不到"确认参与"按钮 | 无法提前确认参与 |
| 3 | 状态标签颜色不一致 | 与详情页风格不统一 |
详情页问题:
| 序号 | 问题 | 影响 |
|---|---|---|
| 1 | 缺少倒计时显示 | 用户不知道距离开始还有多久 |
| 2 | 参与人员列表未展示 | 不知道有哪些人参与 |
| 3 | "确认参与"按钮缺失 | 无法在详情页确认参与 |
1.2 需求分析
这些问题的本质是:不同状态下的UI展示逻辑不完善。
text
状态流转示意:
草稿 → 待开始 → 进行中 → 已结束 → 已完成
↘ 已取消
每个状态需要展示不同的UI元素:
| 状态 | 倒计时 | 确认参与按钮 | 标签颜色 |
|---|---|---|---|
| 草稿 | ❌ | ❌ | 灰色 |
| 待开始 | ✅ 距开始 | ✅ 30分钟内 | 橙色 |
| 进行中 | ✅ 距结束 | ✅ | 绿色 |
| 已结束 | ❌ | ❌ | 蓝色 |
| 已完成 | ❌ | ❌ | 紫色 |
| 已取消 | ❌ | ❌ | 红色 |
二、解决方案
2.1 倒计时实现
核心需求:
- 显示精度到分钟(X天X小时X分)
- 待开始状态:距离开始时间的倒计时
- 进行中状态:距离结束时间的倒计时
- 实时刷新(每秒更新)
实现代码:
javascript
// 倒计时相关状态
const countdownTimers = ref({}); // 存储每个项目的倒计时文本
const countdownInterval = ref(null); // 定时器引用
/**
* 计算倒计时文本
* @param {string} targetTime - 目标时间(开始时间或结束时间)
* @returns {string|null} 格式化的倒计时文本
*/
const calculateCountdown = (targetTime) => {
if (!targetTime) return null;
const now = new Date().getTime();
const target = new Date(targetTime).getTime();
const diff = target - now;
if (diff <= 0) return null;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) {
return `${days}天${hours}小时${minutes}分`;
} else if (hours > 0) {
return `${hours}小时${minutes}分`;
} else {
return `${minutes}分`;
}
};
/**
* 更新所有项目的倒计时
*/
const updateAllCountdowns = () => {
itemList.value.forEach((item) => {
if (item.status !== '待开始' && item.status !== '进行中') return;
const targetTime = item.status === '待开始'
? item.startTime
: item.endTime;
const countdown = calculateCountdown(targetTime);
if (countdown) {
countdownTimers.value[item.id] = countdown;
} else {
delete countdownTimers.value[item.id];
}
});
};
/**
* 启动倒计时定时器
*/
const startCountdownTimer = () => {
if (countdownInterval.value) {
clearInterval(countdownInterval.value);
}
updateAllCountdowns();
countdownInterval.value = setInterval(() => {
updateAllCountdowns();
}, 1000);
};
/**
* 停止倒计时定时器
*/
const stopCountdownTimer = () => {
if (countdownInterval.value) {
clearInterval(countdownInterval.value);
countdownInterval.value = null;
}
};
// 组件挂载时启动定时器
onMounted(() => {
startCountdownTimer();
});
// 组件卸载时清理定时器
onUnmounted(() => {
stopCountdownTimer();
});
模板使用:
vue
<template>
<view v-for="item in itemList" :key="item.id" class="item-card">
<view class="card-header">
<text class="title">{{ item.title }}</text>
<view class="status-badge" :class="getStatusClass(item.status)">
{{ item.status }}
</view>
</view>
<view
v-if="countdownTimers[item.id]"
class="countdown-bar"
>
<text class="countdown-icon">⏰</text>
<text class="countdown-text">
{{ item.status === '待开始' ? '距开始' : '距结束' }}还剩
{{ countdownTimers[item.id] }}
</text>
</view>
</view>
</template>
2.2 状态标签颜色映射
设计思路:
- 不同状态使用不同颜色,直观区分
- 列表页和详情页颜色保持一致
- 使用CSS类名映射,便于维护
实现代码:
javascript
// 状态样式映射
const statusStyleMap = {
草稿: { badge: 'status-badge--draft', text: 'status-text--draft' },
待开始: { badge: 'status-badge--pending', text: 'status-text--pending' },
进行中: { badge: 'status-badge--processing', text: 'status-text--processing' },
已结束: { badge: 'status-badge--ended', text: 'status-text--ended' },
已完成: { badge: 'status-badge--finished', text: 'status-text--finished' },
已取消: { badge: 'status-badge--cancelled', text: 'status-text--cancelled' },
default: { badge: 'status-badge--default', text: 'status-text--default' },
};
const getStatusBadgeClass = (status) => {
return statusStyleMap[status]?.badge || statusStyleMap.default.badge;
};
const getStatusTextClass = (status) => {
return statusStyleMap[status]?.text || statusStyleMap.default.text;
};
CSS样式:
css
/* 状态标签基础样式 */
.status-badge {
padding: 4px 12px;
border-radius: 12px;
border: 1px solid transparent;
}
.status-text {
font-size: 12px;
font-weight: 500;
}
/* 草稿 - 灰色 */
.status-badge--draft {
background-color: #f3f4f6;
border-color: #e5e7eb;
}
.status-text--draft {
color: #4b5563;
}
/* 待开始 - 橙色 */
.status-badge--pending {
background-color: #fff4ec;
border-color: #f9c48a;
}
.status-text--pending {
color: #f79904;
}
/* 进行中 - 绿色 */
.status-badge--processing {
background-color: #dcfce7;
border-color: #bbf7d0;
}
.status-text--processing {
color: #15803d;
}
/* 已结束 - 蓝色 */
.status-badge--ended {
background-color: #dbeafe;
border-color: #bfdbfe;
}
.status-text--ended {
color: #1d4ed8;
}
/* 已完成 - 紫色 */
.status-badge--finished {
background-color: #f3e8ff;
border-color: #e9d5ff;
}
.status-text--finished {
color: #6b21a8;
}
/* 已取消 - 红色 */
.status-badge--cancelled {
background-color: #fee2e2;
border-color: #fecdd3;
}
.status-text--cancelled {
color: #b91c1c;
}
2.3 确认参与按钮权限控制
业务规则:
- 只有参与人员才能看到"确认参与"按钮
- 已确认参与的用户不再显示按钮
- 待开始状态:开始前30分钟内才显示按钮
- 进行中状态:随时可以确认参与
实现代码:
javascript
const confirmButtonVisible = ref({});
/**
* 判断当前用户是否为参与人员
*/
const checkIsParticipant = (item, currentUserId, currentUserType) => {
if (item.isParticipant === true) return true;
if (!Array.isArray(item.participants)) return false;
return item.participants.some(
p => p.userId === currentUserId && p.userType === currentUserType
);
};
/**
* 更新确认参与按钮的显示状态
*/
const updateConfirmButtonState = (item) => {
const itemId = item.id;
if (!item.isParticipant) {
confirmButtonVisible.value[itemId] = false;
return;
}
if (item.isConfirmed) {
confirmButtonVisible.value[itemId] = false;
return;
}
if (item.status === '进行中') {
confirmButtonVisible.value[itemId] = true;
return;
}
if (item.status === '待开始' && item.startTime) {
const now = new Date().getTime();
const start = new Date(item.startTime).getTime();
const diff = start - now;
const thirtyMinutes = 30 * 60 * 1000;
confirmButtonVisible.value[itemId] = diff > 0 && diff <= thirtyMinutes;
return;
}
confirmButtonVisible.value[itemId] = false;
};
/**
* 批量更新所有项目的按钮状态
*/
const updateAllButtonStates = () => {
itemList.value.forEach(item => {
updateConfirmButtonState(item);
});
};
模板使用:
vue
<template>
<view class="item-actions">
<template v-if="item.status === '待开始'">
<button
v-if="item.isCreator"
class="btn-primary"
@click="editItem(item)"
>
编辑
</button>
<button
v-if="item.isCreator"
class="btn-warning"
@click="cancelItem(item)"
>
取消
</button>
<button
v-if="item.isParticipant && !item.isConfirmed && confirmButtonVisible[item.id]"
class="btn-confirm"
@click="confirmParticipation(item)"
>
确认参与
</button>
</template>
<template v-if="item.status === '进行中'">
<button
v-if="item.isParticipant && !item.isConfirmed"
class="btn-confirm"
@click="confirmParticipation(item)"
>
确认参与
</button>
</template>
</view>
</template>
2.4 完整的列表卡片组件
vue
<template>
<view class="item-list">
<view
v-for="item in itemList"
:key="item.id"
class="item-card"
@click="viewDetail(item)"
>
<view class="card-header">
<text class="item-title">{{ item.title || '未命名' }}</text>
<view
class="status-badge"
:class="getStatusBadgeClass(item.status)"
>
<text
class="status-text"
:class="getStatusTextClass(item.status)"
>
{{ item.status || '未知状态' }}
</text>
</view>
</view>
<view class="item-desc">
<text>{{ item.description || '暂无描述' }}</text>
</view>
<view class="info-row">
<text class="icon">⏰</text>
<text>{{ formatDateTime(item.startTime) }}</text>
</view>
<view class="info-row">
<text class="icon">{{ item.type === '线上' ? '💻' : '📍' }}</text>
<text>{{ item.location || '地点待定' }}</text>
</view>
<view class="info-row">
<text>{{ item.participantCount || 0 }}人参与</text>
</view>
<view
v-if="item.status === '待开始' && countdownTimers[item.id]"
class="countdown-bar"
>
<text class="countdown-text">
距开始还剩 {{ countdownTimers[item.id] }}
</text>
</view>
<view class="action-buttons">
<template v-if="item.status === '待开始'">
<button v-if="item.isCreator" @click.stop="editItem(item)">
编辑
</button>
<button v-if="item.isCreator" @click.stop="cancelItem(item)">
取消
</button>
<button
v-if="item.isParticipant && !item.isConfirmed && confirmButtonVisible[item.id]"
class="btn-confirm"
@click.stop="confirmParticipation(item)"
>
确认参与
</button>
</template>
<template v-if="item.status === '进行中'">
<button
v-if="item.isParticipant && !item.isConfirmed"
class="btn-confirm"
@click.stop="confirmParticipation(item)"
>
确认参与
</button>
</template>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const countdownTimers = ref({});
const countdownInterval = ref(null);
const confirmButtonVisible = ref({});
const itemList = ref([]);
const statusStyleMap = {
草稿: { badge: 'status-badge--draft', text: 'status-text--draft' },
待开始: { badge: 'status-badge--pending', text: 'status-text--pending' },
进行中: { badge: 'status-badge--processing', text: 'status-text--processing' },
已结束: { badge: 'status-badge--ended', text: 'status-text--ended' },
已完成: { badge: 'status-badge--finished', text: 'status-text--finished' },
已取消: { badge: 'status-badge--cancelled', text: 'status-text--cancelled' },
};
const getStatusBadgeClass = (status) => statusStyleMap[status]?.badge || 'status-badge--default';
const getStatusTextClass = (status) => statusStyleMap[status]?.text || 'status-text--default';
const calculateCountdown = (targetTime) => {
if (!targetTime) return null;
const diff = new Date(targetTime).getTime() - Date.now();
if (diff <= 0) return null;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}天${hours}小时${minutes}分`;
if (hours > 0) return `${hours}小时${minutes}分`;
return `${minutes}分`;
};
const updateTimersAndButtons = () => {
itemList.value.forEach(item => {
if (item.status === '待开始' || item.status === '进行中') {
const targetTime = item.status === '待开始' ? item.startTime : item.endTime;
const countdown = calculateCountdown(targetTime);
if (countdown) {
countdownTimers.value[item.id] = countdown;
} else {
delete countdownTimers.value[item.id];
}
}
if (!item.isParticipant || item.isConfirmed) {
confirmButtonVisible.value[item.id] = false;
} else if (item.status === '进行中') {
confirmButtonVisible.value[item.id] = true;
} else if (item.status === '待开始' && item.startTime) {
const diff = new Date(item.startTime).getTime() - Date.now();
const thirtyMin = 30 * 60 * 1000;
confirmButtonVisible.value[item.id] = diff > 0 && diff <= thirtyMin;
} else {
confirmButtonVisible.value[item.id] = false;
}
});
};
const startTimer = () => {
updateTimersAndButtons();
countdownInterval.value = setInterval(updateTimersAndButtons, 1000);
};
const stopTimer = () => {
if (countdownInterval.value) {
clearInterval(countdownInterval.value);
countdownInterval.value = null;
}
};
onMounted(startTimer);
onUnmounted(stopTimer);
</script>
<style scoped>
.item-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.item-title {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #006064;
margin-right: 12px;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
flex-shrink: 0;
}
.countdown-bar {
background: #e0f7fa;
padding: 8px 12px;
border-radius: 8px;
margin: 12px 0;
display: flex;
justify-content: flex-end;
}
.countdown-text {
font-size: 12px;
color: #00838f;
font-weight: 500;
}
.btn-confirm {
background: #f79904;
color: #fff;
padding: 6px 16px;
border-radius: 6px;
font-size: 14px;
}
.status-badge--pending { background: #fff4ec; border: 1px solid #f9c48a; }
.status-text--pending { color: #f79904; }
.status-badge--processing { background: #dcfce7; border: 1px solid #bbf7d0; }
.status-text--processing { color: #15803d; }
</style>
三、经验总结
3.1 状态驱动UI的设计模式
javascript
const STATUS_CONFIG = {
待开始: {
badge: { bg: '#fff4ec', border: '#f9c48a', text: '#f79904' },
countdown: { show: true, target: 'startTime', label: '距开始' },
confirmButton: { show: true, condition: 'within30Min' },
},
进行中: {
badge: { bg: '#dcfce7', border: '#bbf7d0', text: '#15803d' },
countdown: { show: true, target: 'endTime', label: '距结束' },
confirmButton: { show: true, condition: 'always' },
},
};
3.2 定时器管理要点
| 要点 | 说明 |
|---|---|
| 及时清理 | 组件卸载时必须清除定时器 |
| 避免重复 | 启动新定时器前先清除旧的 |
| 性能考虑 | 显示到分钟的倒计时,1秒刷新一次足够 |
| 批量更新 | 多个项目共用一个定时器,批量更新状态 |
3.3 权限按钮的判断逻辑
javascript
const shouldShowButton = (item, currentUser) => {
if (!item.isParticipant) return false;
if (item.isConfirmed) return false;
if (item.status === '进行中') return true;
if (item.status === '待开始') {
return isWithin30Minutes(item.startTime);
}
return false;
};
3.4 列表页和详情页的一致性
| 维度 | 处理方式 |
|---|---|
| 状态颜色 | 提取为公共样式/组件 |
| 倒计时逻辑 | 提取为公共工具函数 |
| 按钮权限 | 提取为公共判断方法 |
| 数据格式 | 列表和详情接口返回格式统一 |
四、总结
| 问题 | 解决方案 |
|---|---|
| 倒计时精度 | 实现 calculateCountdown 函数,支持天/小时/分格式 |
| 实时刷新 | 使用 setInterval 每秒更新,注意组件卸载时清理 |
| 状态颜色 | 建立状态-样式映射表,统一管理 |
| 按钮权限 | 综合判断身份、状态、时间窗口三个维度 |
| 一致性 | 列表页和详情页共用样式和逻辑 |
一句话总结:状态驱动UI的核心是建立清晰的"状态→展示"映射规则,并通过统一的配置和函数来保证各处展示一致。
这类"状态驱动UI"的问题在管理系统中非常常见,建立良好的状态配置模式可以大幅降低维护成本。
更多推荐
所有评论(0)