diff --git a/docs/plans/2026-03-04-enhance-download-ui.md b/docs/plans/2026-03-04-enhance-download-ui.md new file mode 100644 index 0000000..3b99119 --- /dev/null +++ b/docs/plans/2026-03-04-enhance-download-ui.md @@ -0,0 +1,1198 @@ +# 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 = { + '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(); + + expect(screen.getByText('Commit ID')).toBeInTheDocument(); + expect(screen.getByText('文件大小')).toBeInTheDocument(); + expect(screen.getByText('创建时间')).toBeInTheDocument(); + }); + + it('should render APK data correctly', () => { + render(); + + 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(); + + 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 ( +
+

暂无 APK 文件

+
+ ); + } + + return ( +
+ + + + + + + + + + + {apks.map((apk) => ( + + + + + + + ))} + +
+ Commit ID + + 文件大小 + + 创建时间 + + 操作 +
+ + {apk.commit || '-'} + + + {formatFileSize(apk.size)} + + {formatDateTime(new Date(apk.modifiedAt))} + + + 下载 + +
+
+ ); +} +``` + +**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( + '/api/projects', + fetcher + ); + + // 获取版本列表 + const { data: versions, isLoading: loadingVersions } = useSWR( + selectedProject ? `/api/versions?project=${selectedProject}` : null, + fetcher + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (selectedProject && selectedVersion) { + onSearch(selectedProject, selectedVersion, commitId); + } + }; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + 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" + /> +
+ + +
+ ); +} +``` + +**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(); + + 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(); + + 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(); + + expect(screen.getByText('加载中...')).toBeInTheDocument(); + }); + + it('should show error state', () => { + (useSWR as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + error: new Error('API error'), + }); + + render(); + + 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('product'); + + const { data: apks, isLoading, error } = useSWR( + `/api/apks?project=${project}&version=${version}&commit=${commitId}`, + fetcher + ); + + if (isLoading) { + return ( +
+ 加载中... +
+ ); + } + + if (error) { + return ( +
+ 加载失败,请重试 +
+ ); + } + + if (!apks || apks.length === 0) { + return ( +
+

未找到匹配的 APK 文件

+
+ ); + } + + // Group APKs by environment + const groupedApks = apks.reduce((acc, apk) => { + acc[apk.environment] = [...(acc[apk.environment] || []), apk]; + return acc; + }, {} as Record); + + return ( +
+ {/* Tab Navigation */} +
+ +
+ + {/* Tab Content */} +
+ +
+
+ ); +} +``` + +**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('/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 ( +
+
+

资源下载

+ +
+
+
+

搜索

+ +
+
+ +
+ {searchParams.project && searchParams.version ? ( + <> +

+ {getProjectDisplayName(searchParams.project)} - {searchParams.version.replace('_', '.')} + {searchParams.commitId && ` (commit: ${searchParams.commitId})`} +

+ + + ) : ( +
+

请选择项目和版本开始搜索

+
+ )} +
+
+
+
+ ); +} +``` + +**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.