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>
1199 lines
33 KiB
Markdown
1199 lines
33 KiB
Markdown
# 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.
|