feat: update ApkList to use tabbed view

Replaced card-based layout with tabbed interface for APK environments.
Now displays APKs grouped by dev/product/sandbox/other tabs with counts.

- Replaced ApkCard with ApkTable component
- Added tab navigation with active state styling
- Group APKs by environment using reduce
- Show count badges for each environment tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:38:20 +08:00
parent d42b72b650
commit 173f182b21
2 changed files with 136 additions and 22 deletions

View File

@@ -1,26 +1,33 @@
'use client'; 'use client';
// app/download/ApkList.tsx import { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import ApkCard from './ApkCard'; 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()); 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 { interface ApkListProps {
project: string; project: string;
version: string; version: string;
commitId: string; commitId: string;
} }
interface Apk { const TABS: ApkEnvironment[] = ['dev', 'product', 'sandbox', 'other'];
name: string;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
export default function ApkList({ project, version, commitId }: ApkListProps) { export default function ApkList({ project, version, commitId }: ApkListProps) {
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
const { data: apks, isLoading, error } = useSWR<Apk[]>( const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`, `/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher fetcher
@@ -44,24 +51,46 @@ export default function ApkList({ project, version, commitId }: ApkListProps) {
if (!apks || apks.length === 0) { if (!apks || apks.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-500"> <div className="bg-white rounded-lg shadow-md p-12 text-center">
APK <p className="text-gray-500"> APK </p>
</div> </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 ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div>
{apks.map((apk) => ( {/* Tab Navigation */}
<ApkCard <div className="bg-white rounded-t-lg border-b border-gray-200">
key={apk.name} <nav className="flex space-x-8 px-6" aria-label="Tabs">
name={apk.name} {TABS.map((tab) => {
commit={apk.commit} const count = groupedApks[tab]?.length || 0;
size={apk.size} return (
modifiedAt={apk.modifiedAt} <button
downloadUrl={apk.downloadUrl} 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> </div>
); );
} }

View File

@@ -0,0 +1,85 @@
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();
});
});