一个看似简单的"状态展示"问题,实际涉及倒计时精度、状态颜色映射、权限按钮显示、定时器管理等多个技术点。本文记录如何系统性地解决这类"状态驱动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 确认参与按钮权限控制

业务规则

  1. 只有参与人员才能看到"确认参与"按钮
  2. 已确认参与的用户不再显示按钮
  3. 待开始状态:开始前30分钟内才显示按钮
  4. 进行中状态:随时可以确认参与

实现代码

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"的问题在管理系统中非常常见,建立良好的状态配置模式可以大幅降低维护成本。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐