Nexus#4: 从后端时序Bug到前端UI/UX的深度打磨

2025-09-23

一、深入理解乐观更新与异步时序

1. 发现问题

在日志(/log)页面,我发现了一个奇怪的现象:创建一个新计时任务并立即开始计时后,如果马上刷新页面,这个任务会“回溯”到未开始的状态。我的临时解决方法是养成一个习惯:每次创建任务后,都等待大约5秒再刷新页面。这显然不是一个合理的解决方案

2. 初步分析

我猜测问题出在前端的“乐观更新”与后端数据库操作之间的时序上。流程大致是:

  • 前端:为了即时反馈,点击“创建”后,UI上立刻出现一个新任务
  • 后端:一个异步请求被发送到服务器,在数据库中创建这条记录
  • 问题点:刷新页面时,前端的临时状态丢失,数据从数据库重新获取。如果此时数据库中的记录状态与UI显示不一致,就会出现“回溯”

3. 深入代码

经过对代码的分析,我发现了导致问题的确切流程:

  1. 乐观更新 (UI):前端在本地状态中创建了一个tempTask,其isRunning状态为false
  2. 创建任务 (API):一个POST请求被发送到/api/timer-tasks,在数据库中创建了一个新任务,其isRunning同样被设置为false
  3. 延迟开始 (UI):在POST请求成功返回后,前端代码使用了一个setTimeout(..., 100)来触发计时开始
  4. 更新状态 (API):在setTimeout的回调中,前端UI状态被更新为isRunning: true同时,又发送了一个PUT请求去更新数据库中任务的状态

问题的根源:在步骤2和步骤4的数据库更新完成之间,存在一个明显的时间窗口。如果用户在此期间刷新页面,数据库中存储的仍然是isRunning: false的状态,导致刷新后任务显示为未开始

// 修复前:分离的创建和开始逻辑
const handleAddToTimer = async (taskName: string, ...) => {
  // 1. 临时任务状态为 isRunning: false
  const tempTask = {
    // ...
    isRunning: false,
    startTime: null,
    // ...
  };

  // 立即更新UI
  setTimerTasks([tempTask, ...timerTasks]);

  try {
    // 2. 向数据库创建一个 isRunning: false 的任务
    const response = await fetch('/api/timer-tasks', {
      method: 'POST',
      body: JSON.stringify({
        // ...
        isRunning: false,
        startTime: null,
        // ...
      }),
    });

    const createdTask = await response.json();

    // 3. 成功后,延迟100ms再开始计时
    setTimeout(() => {
      // 4. 更新UI为运行状态,并发起第二次API调用更新数据库
      setTimerTasks(prevTasks => /* ... update to isRunning: true ... */);
      fetch('/api/timer-tasks', { method: 'PUT', /* ... update isRunning to true ... */ });
    }, 100);
    
  } catch (error) {
    // ...
  }
};

4. 解决方案

核心思想是将“创建任务”和“开始计时”合并为一个原子操作,确保数据库在创建记录时就已经是“正在运行”的状态

  • 后端改造:修改POST /api/timer-tasks接口。在创建新任务时,直接将isRunning设置为true,并记录下当前的startTime。不再需要后续的PUT更新

  • 前端改造

    1. 移除延迟:彻底删除了setTimeout逻辑,不再有100ms的延迟和二次API调用
    2. 状态同步:修改前端的乐观更新逻辑。创建tempTask时,就直接将其状态设置为isRunning: true和当前的startTime,使其与后端将要创建的最终状态保持一致
    3. 数据替换:当后端成功返回创建的任务数据后,用返回的真实数据(包含数据库生成的ID和时间戳)替换掉tempTask
// 修复后:合并的原子操作
const handleAddToTimer = async (taskName: string, ...) => {
  // 1. 临时任务状态直接设为 isRunning: true
  const tempTask = {
    // ...
    isRunning: true,
    startTime: Math.floor(Date.now() / 1000),
    // ...
  };
  
  // 立即更新UI
  setTimerTasks([tempTask, ...timerTasks]);

  try {
    // 2. 向数据库直接创建一个 isRunning: true 的任务
    const response = await fetch('/api/timer-tasks', {
      method: 'POST',
      body: JSON.stringify({
        // ...
        isRunning: true, // 直接设置为运行状态
        startTime: Math.floor(Date.now() / 1000), // 立即设置开始时间
        // ...
      }),
    });

    const createdTask = await response.json();
    
    // 3. 用真实数据替换临时数据,不再有延迟和二次API调用
    setTimerTasks(prevTasks =>
      prevTasks.map(task =>
        task.id === tempTask.id ? { ...createdTask } : task
      )
    );
  } catch (error) {
    // ...
  }
};

二、图表的演进

在日志页面的“时间统计”部分,我希望在保留现有旭日图的基础上,新增一个图表,用于统计“可用事务项”(Instance Tags)的使用耗时

1. 在旭日图中加入切换模式

  • 引入viewMode状态:在组件内部增加了useState<'category' | 'instance'>('category'),用于控制图表显示“按分类”还是“按事务项”
  • 新增API Endpoint:为了获取“总时长”数据,我在后端创建了一个新的API路由/api/timer-tasks/stats/by-instance,它会根据userId查询数据库中所有TimerTask记录,按instanceTag分组并计算总耗时后返回聚合结果
  • 动态数据获取:使用useEffect监听viewMode的变化。当切换到'instance'模式时,组件会异步请求上述新API,并将数据存入instanceStats状态
// EChartsSunburstChart.tsx
const [viewMode, setViewMode] = React.useState<'category' | 'instance'>('category');
const [instanceStats, setInstanceStats] = React.useState<InstanceStat[]>([]);
const [loadingInstance, setLoadingInstance] = React.useState(false);

React.useEffect(() => {
  if (viewMode !== 'instance') return; // 只在事务项模式下加载
  
  const fetchInstanceStats = async () => {
    setLoadingInstance(true);
    try {
      const res = await fetch(`/api/timer-tasks/stats/by-instance?userId=${userId}`);
      const json = await res.json();
      setInstanceStats(json.stats || []);
    } catch (e) {
      console.error('加载事务项统计失败', e);
    } finally {
      setLoadingInstance(false);
    }
  };

  fetchInstanceStats();
}, [viewMode, userId]);

2. 从“全部展示”到“按需选择”

第一版实现了切换,但它会一次性展示所有的“可用事务项”,当事务项增多时,图表会变得非常拥挤

  • 复用组件:我直接复用了已有的InstanceTagSelector组件,它能获取所有可用的事务项并支持多选,保证了UI的一致性
  • 引入筛选状态:在EChartsSunburstChart中增加了selectedInstanceTags状态,用于存储用户选中的事务项
  • 数据过滤:修改buildInstanceSunburstData函数,在构建图表数据前,先根据selectedInstanceTags数组过滤instanceStats。如果筛选数组为空,则显示全部数据

代码例证:加入筛选器和数据过滤逻辑

// EChartsSunburstChart.tsx

// 1. 引入筛选状态
const [selectedInstanceTags, setSelectedInstanceTags] = React.useState<string[]>([]);

// 2. 渲染筛选器组件 (JSX)
{viewMode === 'instance' && (
  <div className="mb-3">
    <InstanceTagSelector
      selectedTags={selectedInstanceTags}
      onTagsChange={setSelectedInstanceTags}
      userId={userId}
    />
  </div>
)}

// 3. 在构建图表数据时应用过滤
const buildInstanceSunburstData = React.useCallback(() => {
  // ...
  const filtered = selectedInstanceTags.length > 0
    ? instanceStats.filter(it => selectedInstanceTags.includes(it.instanceTag))
    : instanceStats; // 如果未选择,则显示全部

  const children: SunburstNode[] = filtered.map(/* ... */);
  // ...
  return root;
}, [instanceStats, selectedInstanceTags]);

三、UI/UX改造

创建事物的模态框在移动端显示效果尚可,但在PC端大屏上显得空旷、分散,布局不美观

1. 统一为移动端逻辑

我简单地移除了所有PC端的响应式样式,虽然解决了PC端过于分散的问题,但也浪费了大屏幕的空间优势

- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 md:gap-8">
+ <div className="grid grid-cols-1 gap-4">

2. PC端2x2网格布局

为了利用PC端的大屏优势,我重新设计了布局,采用了响应式2x2网格

// CreateLogFormWithCards.tsx
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  {/* 左上:快速分类创建 (横跨两列) */}
  <div className="... lg:col-span-2 ...">
    <CategorySelector />
  </div>

  {/* 左下:手动输入区域 */}
  <div className="...">
    <Textarea />
  </div>

  {/* 右下:操作按钮区域 */}
  <div className="...">
    <Button>添加到计时器</Button>
  </div>
</div>

3. 样式美化与Dark Reader适配

布局问题解决后,我开始进行UI美化,并注意了对Dark Reader的兼容,以避免再次出现水合问题

- <Card className="... bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/50 ...">
+ <Card className="... bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm ...">

- <span className="... group-hover:text-blue-700 ...">{topCategory.name}</span>
+ <span className="... group-hover:text-blue-700 dark:group-hover:text-blue-400 ...">{topCategory.name}</span>

- <Button variant="outline" className="... bg-white/80 ...">
+ <Button variant="outline" className="... bg-white/80 dark:bg-gray-700/80 ...">

使用带透明度的背景色(bg-white/90)并为dark模式提供专门的半透明深色背景(dark:bg-gray-800/90),确保了亮色与暗色模式下,UI都足够清晰、美观