Files
ftdl/docs/plans/2026-03-04-enhance-download-ui.md
tech b311589d06 docs: add implementation plan for enhanced download UI
Add comprehensive implementation plan with TDD approach,
bite-sized tasks, and detailed code examples for each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 22:17:31 +08:00

33 KiB

Enhanced Download UI Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Enhance the download UI to display project aliases and classify APKs by environment (dev, sandbox, product, other) in a tabbed list view.

Architecture: Shared utility functions for APK filename parsing + client-side tab organization. Project aliases stored in JSON config file. Server enriches APK data with environment metadata, client organizes into tabs.

Tech Stack: Next.js App Router, TypeScript, SWR, React, Tailwind CSS, Jest


Task 1: Create Project Aliases Configuration File

Files:

  • Create: lib/project-aliases.json

Step 1: Create the project aliases JSON file

{
  "FT": "外放构建",
  "FT_DEV": "开发构建"
}

Step 2: Commit

git add lib/project-aliases.json
git commit -m "feat: add project aliases configuration"

Task 2: Create APK Parser Utilities

Files:

  • Create: lib/apk-parser.ts
  • Test: lib/__tests__/apk-parser.test.ts

Step 1: Write the failing test

// lib/__tests__/apk-parser.test.ts
import { parseApkFilename, detectEnvironment } from '../apk-parser';

describe('parseApkFilename', () => {
  it('should parse APK with environment suffix', () => {
    const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk');
    expect(result.environment).toBe('dev');
    expect(result.commit).toBe('57ef3a60d');
    expect(result.isValid).toBe(true);
  });

  it('should parse APK without environment (product)', () => {
    const result = parseApkFilename('ft_0_42_ff9ff3441.apk');
    expect(result.environment).toBe('product');
    expect(result.commit).toBe('ff9ff3441');
    expect(result.isValid).toBe(true);
  });

  it('should parse sandbox APK', () => {
    const result = parseApkFilename('ft_sandbox_0_42_57ef3a60d.apk');
    expect(result.environment).toBe('sandbox');
    expect(result.commit).toBe('57ef3a60d');
    expect(result.isValid).toBe(true);
  });

  it('should handle invalid format', () => {
    const result = parseApkFilename('invalid_filename.apk');
    expect(result.environment).toBe('other');
    expect(result.commit).toBeNull();
    expect(result.isValid).toBe(false);
  });

  it('should handle missing commit ID', () => {
    const result = parseApkFilename('ft_dev_0_42.apk');
    expect(result.environment).toBe('dev');
    expect(result.commit).toBeNull();
    expect(result.isValid).toBe(false);
  });
});

describe('detectEnvironment', () => {
  it('should detect dev environment', () => {
    expect(detectEnvironment('ft_timeshift_0_42_57ef3a60d.apk')).toBe('dev');
  });

  it('should detect sandbox environment', () => {
    expect(detectEnvironment('ft_sandbox_0_42_57ef3a60d.apk')).toBe('sandbox');
  });

  it('should detect product environment (no suffix)', () => {
    expect(detectEnvironment('ft_0_42_ff9ff3441.apk')).toBe('product');
  });

  it('should return other for unknown environments', () => {
    expect(detectEnvironment('ft_unknown_0_42_57ef3a60d.apk')).toBe('other');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- lib/__tests__/apk-parser.test.ts Expected: FAIL with "module not found"

Step 3: Write minimal implementation

// lib/apk-parser.ts
export type ApkEnvironment = 'dev' | 'sandbox' | 'product' | 'other';

export interface ParsedApkMetadata {
  environment: ApkEnvironment;
  commit: string | null;
  isValid: boolean;
}

// Environment mapping based on filename patterns
const ENVIRONMENT_MAP: Record<string, ApkEnvironment> = {
  'timeshift': 'dev',
  'lan': 'dev',
  'dev': 'dev',
  'sandbox': 'sandbox',
};

export function detectEnvironment(filename: string): ApkEnvironment {
  // Pattern: ft_[environment]_version_commit.apk
  // Extract the environment segment between first and second underscores
  const match = filename.match(/^ft_([^_]+)_/);

  if (!match) {
    // No environment suffix, default to product
    return 'product';
  }

  const env = match[1].toLowerCase();
  return ENVIRONMENT_MAP[env] || 'other';
}

export function parseApkFilename(filename: string): ParsedApkMetadata {
  const environment = detectEnvironment(filename);
  const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
  const commit = commitMatch ? commitMatch[1] : null;
  const isValid = commit !== null;

  return {
    environment,
    commit,
    isValid,
  };
}

Step 4: Run test to verify it passes

Run: npm test -- lib/__tests__/apk-parser.test.ts Expected: PASS

Step 5: Commit

git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts
git commit -m "feat: add APK filename parser utilities"

Task 3: Add Project Alias Utility Function

Files:

  • Modify: lib/utils.ts
  • Test: lib/__tests__/utils.test.ts

Step 1: Write the failing test

// lib/__tests__/utils.test.ts (add to existing file)
import { getProjectDisplayName } from '../utils';

describe('getProjectDisplayName', () => {
  const originalAliases = {
    'FT': '外放构建',
    'FT_DEV': '开发构建',
  };

  beforeEach(() => {
    // Mock the aliases file
    jest.spyOn(require('fs'), 'readFileSync').mockReturnValue(
      JSON.stringify(originalAliases)
    );
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should return display name for aliased project', () => {
    expect(getProjectDisplayName('FT')).toBe('外放构建');
  });

  it('should return directory name when no alias exists', () => {
    expect(getProjectDisplayName('UNKNOWN')).toBe('UNKNOWN');
  });

  it('should return directory name when aliases file is empty', () => {
    jest.spyOn(require('fs'), 'readFileSync').mockReturnValue('{}');
    expect(getProjectDisplayName('FT')).toBe('FT');
  });

  it('should return directory name when aliases file cannot be read', () => {
    jest.spyOn(require('fs'), 'readFileSync').mockImplementation(() => {
      throw new Error('File not found');
    });
    expect(getProjectDisplayName('FT')).toBe('FT');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- lib/__tests__/utils.test.ts -t getProjectDisplayName Expected: FAIL with "function not defined"

Step 3: Write minimal implementation

// lib/utils.ts (add to existing file)
import * as fs from 'fs';
import * as path from 'path';

// Client-safe utility functions

/**
 * 格式化版本号显示 (0_42 -> 0.42)
 */
export function formatVersion(version: string): string {
  return version.replace(/_/g, '.');
}

/**
 * 格式化文件大小
 */
export function formatFileSize(bytes: number): string {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
  if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
  return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}

/**
 * 格式化日期时间
 */
export function formatDateTime(date: Date): string {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  const hours = String(date.getHours()).padStart(2, '0');
  const minutes = String(date.getMinutes()).padStart(2, '0');
  return `${year}-${month}-${day} ${hours}:${minutes}`;
}

/**
 * 获取项目显示名称(从别名配置)
 */
export function getProjectDisplayName(projectName: string): string {
  try {
    const aliasesPath = path.join(process.cwd(), 'lib', 'project-aliases.json');
    const aliasesContent = fs.readFileSync(aliasesPath, 'utf-8');
    const aliases = JSON.parse(aliasesContent);
    return aliases[projectName] || projectName;
  } catch {
    // If file doesn't exist or is invalid, return original name
    return projectName;
  }
}

Step 4: Run test to verify it passes

Run: npm test -- lib/__tests__/utils.test.ts -t getProjectDisplayName Expected: PASS

Step 5: Commit

git add lib/utils.ts lib/__tests__/utils.test.ts
git commit -m "feat: add getProjectDisplayName utility"

Task 4: Update Projects API to Include Display Names

Files:

  • Modify: app/api/projects/route.ts
  • Test: app/api/__tests__/projects.test.ts

Step 1: Write the failing test

// app/api/__tests__/projects.test.ts
import { GET } from '../projects/route';
import { getProjects } from '@/lib/fs-utils';

jest.mock('@/lib/fs-utils');
jest.mock('@/lib/utils', () => ({
  getProjectDisplayName: jest.fn((name) => name === 'FT' ? '外放构建' : name),
}));

describe('/api/projects', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should return projects with display names', async () => {
    (getProjects as jest.Mock).mockResolvedValue(['FT', 'FT_DEV', 'UNKNOWN']);

    const response = await GET();
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toEqual([
      { name: 'FT', displayName: '外放构建' },
      { name: 'FT_DEV', displayName: 'FT_DEV' },
      { name: 'UNKNOWN', displayName: 'UNKNOWN' },
    ]);
  });

  it('should handle errors gracefully', async () => {
    (getProjects as jest.Mock).mockRejectedValue(new Error('File system error'));

    const response = await GET();
    const data = await response.json();

    expect(response.status).toBe(500);
    expect(data.error).toBe('Failed to fetch projects');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- app/api/__tests__/projects.test.ts Expected: FAIL with response structure mismatch

Step 3: Write minimal implementation

// app/api/projects/route.ts
import { NextResponse } from 'next/server';
import { getProjects } from '@/lib/fs-utils';
import { getProjectDisplayName } from '@/lib/utils';

export async function GET() {
  try {
    const projects = await getProjects();
    const projectsWithAliases = projects.map(project => ({
      name: project,
      displayName: getProjectDisplayName(project),
    }));
    return NextResponse.json(projectsWithAliases);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch projects' },
      { status: 500 }
    );
  }
}

Step 4: Run test to verify it passes

Run: npm test -- app/api/__tests__/projects.test.ts Expected: PASS

Step 5: Commit

git add app/api/projects/route.ts app/api/__tests__/projects.test.ts
git commit -m "feat: update projects API to include display names"

Task 5: Update APKs API to Include Environment Metadata

Files:

  • Modify: app/api/apks/route.ts
  • Test: app/api/__tests__/apks.test.ts

Step 1: Write the failing test

// app/api/__tests__/apks.test.ts
import { GET } from '../apks/route';
import { getApks } from '@/lib/fs-utils';
import { parseApkFilename } from '@/lib/apk-parser';

jest.mock('@/lib/fs-utils');
jest.mock('@/lib/apk-parser');

describe('/api/apks', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should return APKs with environment metadata', async () => {
    const mockApks = [
      {
        name: 'ft_timeshift_0_42_57ef3a60d.apk',
        size: 15728640,
        modifiedAt: new Date('2026-03-04T14:30:00Z'),
      },
      {
        name: 'ft_0_42_ff9ff3441.apk',
        size: 15728640,
        modifiedAt: new Date('2026-03-04T14:30:00Z'),
      },
    ];

    (getApks as jest.Mock).mockResolvedValue(mockApks);
    (parseApkFilename as jest.Mock)
      .mockReturnValueOnce({ environment: 'dev', commit: '57ef3a60d', isValid: true })
      .mockReturnValueOnce({ environment: 'product', commit: 'ff9ff3441', isValid: true });

    const request = new Request('http://localhost:3000/api/apks?project=FT&version=0_42');
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toHaveLength(2);
    expect(data[0]).toMatchObject({
      name: 'ft_timeshift_0_42_57ef3a60d.apk',
      environment: 'dev',
      commit: '57ef3a60d',
    });
    expect(data[1]).toMatchObject({
      name: 'ft_0_42_ff9ff3441.apk',
      environment: 'product',
      commit: 'ff9ff3441',
    });
  });

  it('should require project and version parameters', async () => {
    const request = new Request('http://localhost:3000/api/apks');
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(400);
    expect(data.error).toBe('Project and version parameters are required');
  });

  it('should handle errors gracefully', async () => {
    (getApks as jest.Mock).mockRejectedValue(new Error('File system error'));

    const request = new Request('http://localhost:3000/api/apks?project=FT&version=0_42');
    const response = await GET(request);
    const data = await response.json();

    expect(response.status).toBe(500);
    expect(data.error).toBe('Failed to fetch APK files');
  });
});

Step 2: Run test to verify it fails

Run: npm test -- app/api/__tests__/apks.test.ts Expected: FAIL with environment field missing

Step 3: Write minimal implementation

// app/api/apks/route.ts
import { NextResponse } from 'next/server';
import { getApks } from '@/lib/fs-utils';
import { parseApkFilename } from '@/lib/apk-parser';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const project = searchParams.get('project');
  const version = searchParams.get('version');
  const commitId = searchParams.get('commit') || undefined;

  if (!project || !version) {
    return NextResponse.json(
      { error: 'Project and version parameters are required' },
      { status: 400 }
    );
  }

  try {
    const apks = await getApks(project, version, commitId);
    const apksWithMetadata = apks.map(apk => {
      const parsed = parseApkFilename(apk.name);
      return {
        ...apk,
        environment: parsed.environment,
        commit: parsed.commit,
        modifiedAt: apk.modifiedAt.toISOString(),
        downloadUrl: `/api/download?project=${encodeURIComponent(project)}&version=${encodeURIComponent(version)}&filename=${encodeURIComponent(apk.name)}`,
      };
    });
    return NextResponse.json(apksWithMetadata);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch APK files' },
      { status: 500 }
    );
  }
}

Step 4: Run test to verify it passes

Run: npm test -- app/api/__tests__/apks.test.ts Expected: PASS

Step 5: Commit

git add app/api/apks/route.ts app/api/__tests__/apks.test.ts
git commit -m "feat: update APKs API to include environment metadata"

Task 6: Create ApkTable Component

Files:

  • Create: app/download/ApkTable.tsx
  • Test: app/download/__tests__/ApkTable.test.tsx

Step 1: Write the failing test

// app/download/__tests__/ApkTable.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import ApkTable from '../ApkTable';

describe('ApkTable', () => {
  const mockApks = [
    {
      name: 'ft_timeshift_0_42_57ef3a60d.apk',
      commit: '57ef3a60d',
      size: 15728640,
      modifiedAt: '2026-03-04T14:30:00.000Z',
      downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_timeshift_0_42_57ef3a60d.apk',
    },
  ];

  it('should render table with correct columns', () => {
    render(<ApkTable apks={mockApks} />);

    expect(screen.getByText('Commit ID')).toBeInTheDocument();
    expect(screen.getByText('文件大小')).toBeInTheDocument();
    expect(screen.getByText('创建时间')).toBeInTheDocument();
  });

  it('should render APK data correctly', () => {
    render(<ApkTable apks={mockApks} />);

    expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
    expect(screen.getByText('15.00 MB')).toBeInTheDocument();
    expect(screen.getByText('2026-03-04 14:30')).toBeInTheDocument();
    expect(screen.getByText('下载')).toBeInTheDocument();
  });

  it('should show empty state when no APKs', () => {
    render(<ApkTable apks={[]} />);

    expect(screen.getByText('暂无 APK 文件')).toBeInTheDocument();
  });
});

Step 2: Run test to verify it fails

Run: npm test -- app/download/__tests__/ApkTable.test.tsx Expected: FAIL with "module not found"

Step 3: Write minimal implementation

// app/download/ApkTable.tsx
'use client';

import { formatFileSize, formatDateTime } from '@/lib/utils';

interface Apk {
  name: string;
  commit: string | null;
  size: number;
  modifiedAt: string;
  downloadUrl: string;
}

interface ApkTableProps {
  apks: Apk[];
}

export default function ApkTable({ apks }: ApkTableProps) {
  if (!apks || apks.length === 0) {
    return (
      <div className="bg-white rounded-lg shadow-md p-12 text-center">
        <p className="text-gray-500">暂无 APK 文件</p>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow-md overflow-hidden">
      <table className="w-full">
        <thead className="bg-gray-50 border-b border-gray-200">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              Commit ID
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              文件大小
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              创建时间
            </th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
              操作
            </th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {apks.map((apk) => (
            <tr key={apk.name} className="hover:bg-gray-50">
              <td className="px-6 py-4 whitespace-nowrap">
                <span className="font-mono text-sm text-gray-900">
                  {apk.commit || '-'}
                </span>
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {formatFileSize(apk.size)}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {formatDateTime(new Date(apk.modifiedAt))}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                <a
                  href={apk.downloadUrl}
                  download
                  rel="noopener noreferrer"
                  className="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
                >
                  下载
                </a>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Step 4: Run test to verify it passes

Run: npm test -- app/download/__tests__/ApkTable.test.tsx Expected: PASS

Step 5: Commit

git add app/download/ApkTable.tsx app/download/__tests__/ApkTable.test.tsx
git commit -m "feat: add ApkTable component"

Task 7: Update SearchForm to Use Project Display Names

Files:

  • Modify: app/download/SearchForm.tsx

Step 1: Update SearchForm to handle project display names

// app/download/SearchForm.tsx (replace existing file)
'use client';

import { useState } from 'react';
import useSWR from 'swr';
import { formatVersion } from '@/lib/utils';

const fetcher = async (url: string) => {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`HTTP error: ${res.status}`);
  }
  return res.json();
};

interface Project {
  name: string;
  displayName: string;
}

interface SearchFormProps {
  onSearch: (project: string, version: string, commitId: string) => void;
}

export default function SearchForm({ onSearch }: SearchFormProps) {
  const [selectedProject, setSelectedProject] = useState('');
  const [selectedVersion, setSelectedVersion] = useState('');
  const [commitId, setCommitId] = useState('');

  // 获取项目列表
  const { data: projects, isLoading: loadingProjects } = useSWR<Project[]>(
    '/api/projects',
    fetcher
  );

  // 获取版本列表
  const { data: versions, isLoading: loadingVersions } = useSWR<string[]>(
    selectedProject ? `/api/versions?project=${selectedProject}` : null,
    fetcher
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (selectedProject && selectedVersion) {
      onSearch(selectedProject, selectedVersion, commitId);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="project" className="block text-sm font-medium text-gray-700 mb-2">
          项目
        </label>
        <select
          id="project"
          value={selectedProject}
          onChange={(e) => {
            setSelectedProject(e.target.value);
            setSelectedVersion('');
          }}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          required
        >
          <option value="">选择项目</option>
          {loadingProjects ? (
            <option disabled>加载中...</option>
          ) : (
            projects?.map((project) => (
              <option key={project.name} value={project.name}>
                {project.displayName}
              </option>
            ))
          )}
        </select>
      </div>

      <div>
        <label htmlFor="version" className="block text-sm font-medium text-gray-700 mb-2">
          版本
        </label>
        <select
          id="version"
          value={selectedVersion}
          onChange={(e) => setSelectedVersion(e.target.value)}
          disabled={!selectedProject}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
          required
        >
          <option value="">选择版本</option>
          {loadingVersions ? (
            <option disabled>加载中...</option>
          ) : (
            versions?.map((version) => (
              <option key={version} value={version}>
                {formatVersion(version)}
              </option>
            ))
          )}
        </select>
      </div>

      <div>
        <label htmlFor="commit" className="block text-sm font-medium text-gray-700 mb-2">
          Commit ID (可选)
        </label>
        <input
          type="text"
          id="commit"
          value={commitId}
          onChange={(e) => setCommitId(e.target.value)}
          placeholder="输入 commit id..."
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <button
        type="submit"
        disabled={!selectedProject || !selectedVersion}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
      >
        查询
      </button>
    </form>
  );
}

Step 2: Run tests to verify no breaking changes

Run: npm test Expected: PASS

Step 3: Commit

git add app/download/SearchForm.tsx
git commit -m "feat: update SearchForm to use project display names"

Task 8: Update ApkList to Use Tabbed View

Files:

  • Modify: app/download/ApkList.tsx
  • Test: app/download/__tests__/ApkList.test.tsx

Step 1: Write the failing test

// app/download/__tests__/ApkList.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ApkList from '../ApkList';
import useSWR from 'swr';

jest.mock('swr');

describe('ApkList', () => {
  const mockApks = [
    {
      name: 'ft_timeshift_0_42_57ef3a60d.apk',
      environment: 'dev',
      commit: '57ef3a60d',
      size: 15728640,
      modifiedAt: '2026-03-04T14:30:00.000Z',
      downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_timeshift_0_42_57ef3a60d.apk',
    },
    {
      name: 'ft_0_42_ff9ff3441.apk',
      environment: 'product',
      commit: 'ff9ff3441',
      size: 15728640,
      modifiedAt: '2026-03-04T14:30:00.000Z',
      downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_0_42_ff9ff3441.apk',
    },
    {
      name: 'ft_sandbox_0_42_57ef3a60d.apk',
      environment: 'sandbox',
      commit: '57ef3a60d',
      size: 15728640,
      modifiedAt: '2026-03-04T14:30:00.000Z',
      downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_sandbox_0_42_57ef3a60d.apk',
    },
  ];

  beforeEach(() => {
    (useSWR as jest.Mock).mockReturnValue({
      data: mockApks,
      isLoading: false,
      error: null,
    });
  });

  it('should render all 4 tabs', () => {
    render(<ApkList project="FT" version="0_42" commitId="" />);

    expect(screen.getByText('dev')).toBeInTheDocument();
    expect(screen.getByText('product')).toBeInTheDocument();
    expect(screen.getByText('sandbox')).toBeInTheDocument();
    expect(screen.getByText('other')).toBeInTheDocument();
  });

  it('should switch tabs correctly', () => {
    render(<ApkList project="FT" version="0_42" commitId="" />);

    const devTab = screen.getByText('dev');
    fireEvent.click(devTab);

    expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
  });

  it('should show loading state', () => {
    (useSWR as jest.Mock).mockReturnValue({
      data: null,
      isLoading: true,
      error: null,
    });

    render(<ApkList project="FT" version="0_42" commitId="" />);

    expect(screen.getByText('加载中...')).toBeInTheDocument();
  });

  it('should show error state', () => {
    (useSWR as jest.Mock).mockReturnValue({
      data: null,
      isLoading: false,
      error: new Error('API error'),
    });

    render(<ApkList project="FT" version="0_42" commitId="" />);

    expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
  });
});

Step 2: Run test to verify it fails

Run: npm test -- app/download/__tests__/ApkList.test.tsx Expected: FAIL with tab elements missing

Step 3: Write minimal implementation

// app/download/ApkList.tsx (replace existing file)
'use client';

import { useState } from 'react';
import useSWR from 'swr';
import { formatVersion } from '@/lib/utils';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';

const fetcher = (url: string) => fetch(url).then((res) => res.json());

interface Apk {
  name: string;
  environment: ApkEnvironment;
  commit: string | null;
  size: number;
  modifiedAt: string;
  downloadUrl: string;
}

interface ApkListProps {
  project: string;
  version: string;
  commitId: string;
}

const TABS: ApkEnvironment[] = ['dev', 'product', 'sandbox', 'other'];

export default function ApkList({ project, version, commitId }: ApkListProps) {
  const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');

  const { data: apks, isLoading, error } = useSWR<Apk[]>(
    `/api/apks?project=${project}&version=${version}&commit=${commitId}`,
    fetcher
  );

  if (isLoading) {
    return (
      <div className="text-center py-8 text-gray-500">
        加载中...
      </div>
    );
  }

  if (error) {
    return (
      <div className="text-center py-8 text-red-600">
        加载失败,请重试
      </div>
    );
  }

  if (!apks || apks.length === 0) {
    return (
      <div className="bg-white rounded-lg shadow-md p-12 text-center">
        <p className="text-gray-500">未找到匹配的 APK 文件</p>
      </div>
    );
  }

  // Group APKs by environment
  const groupedApks = apks.reduce((acc, apk) => {
    acc[apk.environment] = [...(acc[apk.environment] || []), apk];
    return acc;
  }, {} as Record<ApkEnvironment, Apk[]>);

  return (
    <div>
      {/* Tab Navigation */}
      <div className="bg-white rounded-t-lg border-b border-gray-200">
        <nav className="flex space-x-8 px-6" aria-label="Tabs">
          {TABS.map((tab) => {
            const count = groupedApks[tab]?.length || 0;
            return (
              <button
                key={tab}
                onClick={() => setActiveTab(tab)}
                className={`py-4 px-1 border-b-2 font-medium text-sm ${
                  activeTab === tab
                    ? 'border-blue-500 text-blue-600'
                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                }`}
              >
                {tab} ({count})
              </button>
            );
          })}
        </nav>
      </div>

      {/* Tab Content */}
      <div className="mt-4">
        <ApkTable apks={groupedApks[activeTab] || []} />
      </div>
    </div>
  );
}

Step 4: Run test to verify it passes

Run: npm test -- app/download/__tests__/ApkList.test.tsx Expected: PASS

Step 5: Run all tests to ensure no regressions

Run: npm test Expected: PASS

Step 6: Commit

git add app/download/ApkList.tsx app/download/__tests__/ApkList.test.tsx
git commit -m "feat: update ApkList to use tabbed view"

Task 9: Update Download Page Title to Use Display Name

Files:

  • Modify: app/download/page.tsx

Step 1: Update page to show project display name

// app/download/page.tsx (modify the existing file)
'use client';

import { useState } from 'react';
import useSWR from 'swr';
import SearchForm from './SearchForm';
import ApkList from './ApkList';

interface Project {
  name: string;
  displayName: string;
}

export default function DownloadPage() {
  const [searchParams, setSearchParams] = useState({
    project: '',
    version: '',
    commitId: '',
  });

  const { data: projects } = useSWR<Project[]>('/api/projects');

  const handleSearch = (project: string, version: string, commitId: string) => {
    setSearchParams({ project, version, commitId });
  };

  const getProjectDisplayName = (projectName: string): string => {
    const project = projects?.find(p => p.name === projectName);
    return project?.displayName || projectName;
  };

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        <h1 className="text-3xl font-bold text-gray-900 mb-8">资源下载</h1>

        <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
          <div className="lg:col-span-1">
            <div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
              <h2 className="text-xl font-semibold text-gray-800 mb-6">搜索</h2>
              <SearchForm onSearch={handleSearch} />
            </div>
          </div>

          <div className="lg:col-span-2">
            {searchParams.project && searchParams.version ? (
              <>
                <h2 className="text-xl font-semibold text-gray-800 mb-4">
                  {getProjectDisplayName(searchParams.project)} - {searchParams.version.replace('_', '.')}
                  {searchParams.commitId && ` (commit: ${searchParams.commitId})`}
                </h2>
                <ApkList
                  project={searchParams.project}
                  version={searchParams.version}
                  commitId={searchParams.commitId}
                />
              </>
            ) : (
              <div className="bg-white rounded-lg shadow-md p-12 text-center">
                <p className="text-gray-500">请选择项目和版本开始搜索</p>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Step 2: Run tests to verify no breaking changes

Run: npm test Expected: PASS

Step 3: Commit

git add app/download/page.tsx
git commit -m "feat: update download page title to use display name"

Task 10: Final Integration Testing

Files:

  • Test: Integration testing across all components

Step 1: Run full test suite

Run: npm test Expected: All tests PASS

Step 2: Build the project to verify no TypeScript errors

Run: npm run build Expected: Build succeeds

Step 3: Start development server and manually test

Run: npm run dev Expected: Server starts without errors

Manual Testing Checklist:

  • Navigate to download page
  • Select a project (verify alias display)
  • Select a version
  • Verify all 4 tabs appear (dev, product, sandbox, other)
  • Switch between tabs
  • Verify APK list shows correct columns
  • Test commit filter
  • Download an APK
  • Test with project that has no alias (verify directory name shows)

Step 4: Commit final cleanup if needed

git add .
git commit -m "test: complete integration testing for enhanced download UI"

Summary

This implementation plan creates a fully functional enhanced download UI with:

  1. Project aliases - Stored in JSON config, fallback to directory names
  2. APK environment classification - Parsed from filename, grouped into tabs
  3. Tabbed list view - 4 tabs (dev, product, sandbox, other)
  4. List format - Shows Commit ID, File Size, Creation Time
  5. Global commit filter - Applies to all tabs
  6. Comprehensive tests - Unit, integration, and component tests
  7. Type safety - Full TypeScript support throughout

All tasks follow TDD principles with bite-sized steps and frequent commits.