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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
85
app/download/__tests__/ApkList.test.tsx
Normal file
85
app/download/__tests__/ApkList.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user