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

1199 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```json
{
"FT": "外放构建",
"FT_DEV": "开发构建"
}
```
**Step 2: Commit**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```bash
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**
```bash
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.