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>
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:
- Project aliases - Stored in JSON config, fallback to directory names
- APK environment classification - Parsed from filename, grouped into tabs
- Tabbed list view - 4 tabs (dev, product, sandbox, other)
- List format - Shows Commit ID, File Size, Creation Time
- Global commit filter - Applies to all tabs
- Comprehensive tests - Unit, integration, and component tests
- Type safety - Full TypeScript support throughout
All tasks follow TDD principles with bite-sized steps and frequent commits.