Compare commits

..

10 Commits

Author SHA1 Message Date
d1ad58b504 refactor: simplify apk filename parsing logic
- Simplify parseApkFilename to use version delimiter split
- Update tests to match new data structure with rawEnvironment
- Remove unnecessary test cases and assertions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:27:54 +08:00
8d7d2f37d7 fix: show actual environment name for 'other' category
- Add rawEnvironment field to preserve extracted environment name
- Display rawEnvironment (e.g., 'timeshift') instead of 'other' in badge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:13:52 +08:00
31bbebc2ab feat: add environment badge to commit id column in ApkTable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:46:05 +08:00
0b86887680 fix: improve SearchForm text visibility with darker color
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:44:10 +08:00
d22aa276e8 docs: add download page readability implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:39:02 +08:00
ddf2729426 docs: add download page readability design
- Fix SearchForm text color for better visibility
- Add environment tags to commit id column in ApkList

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:38:02 +08:00
1eba2f5f39 chore: update env example and gitignore, add implementation plan
- Update .env.example with documentation and default values
- Add .resources to gitignore (dummy test files)
- Add APK parsing fix implementation plan document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:06:44 +08:00
7268b91ed3 feat: add config API and update ApkList to use configured tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:54:31 +08:00
0b817984bb feat: pass version parameter to APK parser
Update parseApkFilename call to include the version parameter,
allowing the parser to use version-based configuration when
parsing APK filenames.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:52:53 +08:00
df4daea1c7 feat: rewrite APK parser to use version as delimiter
- Changed parseApkFilename to accept version parameter
- New parsing logic splits filename by version string to extract prefix
- detectEnvironment now takes prefix and configured tabs as arguments
- Environments not in APK_TABS are mapped to 'other' instead of hardcoded mapping
- Added getApkTabs helper function to expose configured tabs
- Updated all tests to use new API with version parameter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:33:45 +08:00
14 changed files with 939 additions and 97 deletions

View File

@@ -1,3 +1,9 @@
AUTH_USERNAME=
AUTH_PASSWORD=
RESOURCE_PATH=
# Authentication credentials (change in production!)
AUTH_USERNAME=admin
AUTH_PASSWORD=changeme
# Path to APK storage directory
RESOURCE_PATH=./resources
# Comma-separated list of tab names for APK filtering
APK_TABS=dev,sandbox

3
.gitignore vendored
View File

@@ -40,3 +40,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.env
# dumy files for download tests
.resources

View File

@@ -19,11 +19,12 @@ export async function GET(request: Request) {
try {
const apks = await getApks(project, version, commitId);
const apksWithMetadata = apks.map(apk => {
const parsed = parseApkFilename(apk.name);
const parsed = parseApkFilename(apk.name, version);
return {
name: apk.name,
size: apk.size,
environment: parsed.environment,
rawEnvironment: parsed.rawEnvironment,
commit: parsed.commit,
modifiedAt: apk.modifiedAt.toISOString(),
downloadUrl: `/api/download?project=${encodeURIComponent(project)}&version=${encodeURIComponent(version)}&filename=${encodeURIComponent(apk.name)}`,

8
app/api/config/route.ts Normal file
View File

@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { APK_TABS } from '@/lib/constants';
export async function GET() {
return NextResponse.json({
tabs: APK_TABS,
});
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';
@@ -10,6 +10,7 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface Apk {
name: string;
environment: ApkEnvironment;
rawEnvironment: string;
commit: string | null;
size: number;
modifiedAt: string;
@@ -22,16 +23,26 @@ interface ApkListProps {
commitId: string;
}
const TABS: ApkEnvironment[] = ['dev', 'product', 'sandbox', 'other'];
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product -> configured -> other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500">

View File

@@ -1,9 +1,12 @@
'use client';
import { formatFileSize, formatDateTime } from '@/lib/utils';
import { type ApkEnvironment } from '@/lib/apk-parser';
interface Apk {
name: string;
environment: ApkEnvironment;
rawEnvironment: string;
commit: string | null;
size: number;
modifiedAt: string;
@@ -14,6 +17,18 @@ interface ApkTableProps {
apks: Apk[];
}
function getEnvironmentBadgeClass(env: ApkEnvironment): string {
switch (env) {
case 'product':
return 'bg-blue-100 text-blue-800';
case 'dev':
case 'sandbox':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
export default function ApkTable({ apks }: ApkTableProps) {
if (!apks || apks.length === 0) {
return (
@@ -46,9 +61,14 @@ export default function ApkTable({ apks }: ApkTableProps) {
{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>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-gray-900">
{apk.commit || '-'}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${getEnvironmentBadgeClass(apk.environment)}`}>
{apk.rawEnvironment}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatFileSize(apk.size)}

View File

@@ -58,7 +58,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
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"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
required
>
<option value=""></option>
@@ -83,7 +83,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
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"
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 text-gray-900"
required
>
<option value=""></option>
@@ -109,7 +109,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
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"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>

View File

@@ -1,35 +1,51 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import ApkList from '../ApkList';
import useSWR from 'swr';
jest.mock('swr');
// Mock fetch for config API
const mockFetch = jest.fn();
global.fetch = mockFetch;
describe('ApkList', () => {
const mockApks = [
{
name: 'ft_timeshift_0_42_57ef3a60d.apk',
name: 'ft_dev_0_42_57ef3a60d.apk',
environment: 'dev',
rawEnvironment: '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',
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_dev_0_42_57ef3a60d.apk',
},
{
name: 'ft_0_42_ff9ff3441.apk',
environment: 'product',
rawEnvironment: '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',
name: 'ft_sandbox_0_42_abc123456.apk',
environment: 'sandbox',
commit: '57ef3a60d',
rawEnvironment: 'sandbox',
commit: 'abc123456',
size: 15728640,
modifiedAt: '2026-03-04T14:30:00.000Z',
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_sandbox_0_42_57ef3a60d.apk',
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_sandbox_0_42_abc123456.apk',
},
{
name: 'ft_timeshift_0_42_xyz987654.apk',
environment: 'other',
rawEnvironment: 'timeshift',
commit: 'xyz987654',
size: 15728640,
modifiedAt: '2026-03-04T14:30:00.000Z',
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_timeshift_0_42_xyz987654.apk',
},
];
@@ -39,19 +55,38 @@ describe('ApkList', () => {
isLoading: false,
error: null,
});
// Mock config API response
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ tabs: ['dev', 'sandbox'] }),
});
});
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();
afterEach(() => {
mockFetch.mockClear();
});
it('should switch tabs correctly', () => {
render(<ApkList project="FT" version="0_42" commitId="" />);
it('should render all 4 tabs', async () => {
await act(async () => {
render(<ApkList project="FT" version="0_42" commitId="" />);
});
await waitFor(() => {
// Use getAllByText since text appears in both tab and badge
expect(screen.getAllByText(/dev/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/product/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/sandbox/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/other/).length).toBeGreaterThan(0);
});
});
it('should switch tabs correctly', async () => {
await act(async () => {
render(<ApkList project="FT" version="0_42" commitId="" />);
});
await waitFor(() => {
expect(screen.getByText(/dev/)).toBeInTheDocument();
});
const devTab = screen.getByText(/dev/);
fireEvent.click(devTab);
@@ -59,26 +94,30 @@ describe('ApkList', () => {
expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
});
it('should show loading state', () => {
it('should show loading state', async () => {
(useSWR as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
error: null,
});
render(<ApkList project="FT" version="0_42" commitId="" />);
await act(async () => {
render(<ApkList project="FT" version="0_42" commitId="" />);
});
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should show error state', () => {
it('should show error state', async () => {
(useSWR as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
error: new Error('API error'),
});
render(<ApkList project="FT" version="0_42" commitId="" />);
await act(async () => {
render(<ApkList project="FT" version="0_42" commitId="" />);
});
expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
});

View File

@@ -0,0 +1,532 @@
# APK Parsing Fix Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix APK filename parsing to use version number as delimiter and support configurable environment tabs.
**Architecture:** Parse APK filenames by splitting on `_<version>` to extract prefix. If prefix has no underscore → product. If prefix has underscore → extract environment from last segment. Match against configurable tabs (env var), unmatched → other.
**Tech Stack:** Next.js, TypeScript, environment variables
---
### Task 1: Add APK_TABS Environment Variable
**Files:**
- Modify: `lib/constants.ts`
**Step 1: Add APK_TABS constant**
```typescript
// Add to lib/constants.ts
export const APK_TABS = (process.env.APK_TABS || 'dev,sandbox')
.split(',')
.map(t => t.trim())
.filter(Boolean);
```
**Step 2: Verify no build errors**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add lib/constants.ts
git commit -m "feat: add APK_TABS environment variable for configurable tabs"
```
---
### Task 2: Rewrite APK Parser Logic
**Files:**
- Modify: `lib/apk-parser.ts`
- Modify: `lib/__tests__/apk-parser.test.ts`
**Step 1: Write failing tests for new parsing logic**
Replace `lib/__tests__/apk-parser.test.ts`:
```typescript
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
describe('parseApkFilename', () => {
it('should parse product APK (no environment)', () => {
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('product');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse dev APK', () => {
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse sandbox APK', () => {
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('sandbox');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should map unknown environment to other', () => {
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBe('bfeddf2d0');
});
it('should map timeshift to other', () => {
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
expect(result.environment).toBe('other');
});
it('should handle invalid format without commit', () => {
const result = parseApkFilename('ft_dev_0_44.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
it('should handle completely invalid filename', () => {
const result = parseApkFilename('invalid_filename.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
});
describe('detectEnvironment', () => {
it('should detect product when no underscore in prefix', () => {
expect(detectEnvironment('ft', ['dev', 'sandbox'])).toBe('product');
});
it('should detect configured environment', () => {
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toBe('dev');
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toBe('sandbox');
});
it('should return other for unconfigured environment', () => {
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toBe('other');
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toBe('other');
});
it('should return other for empty prefix', () => {
expect(detectEnvironment('', ['dev', 'sandbox'])).toBe('other');
});
});
```
**Step 2: Run tests to verify they fail**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: Tests fail with missing `getApkTabs` and wrong behavior
**Step 3: Implement new parsing logic**
Replace `lib/apk-parser.ts`:
```typescript
import { APK_TABS } from './constants';
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
export interface ParsedApkMetadata {
environment: ApkEnvironment;
commit: string | null;
isValid: boolean;
}
export function getApkTabs(): string[] {
return APK_TABS;
}
export function detectEnvironment(prefix: string, configuredTabs: string[]): ApkEnvironment {
// Empty prefix → other
if (!prefix) {
return 'other';
}
// No underscore means product (e.g., "ft")
if (!prefix.includes('_')) {
return 'product';
}
// Extract environment from last segment after underscore
const lastUnderscoreIndex = prefix.lastIndexOf('_');
const env = prefix.substring(lastUnderscoreIndex + 1);
// Check if environment is configured
if (configuredTabs.includes(env)) {
return env as ApkEnvironment;
}
return 'other';
}
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
// Use version as delimiter (with leading underscore)
const delimiter = `_${version}`;
const delimiterIndex = filename.indexOf(delimiter);
let prefix: string;
if (delimiterIndex > 0) {
prefix = filename.substring(0, delimiterIndex);
} else {
// Fallback: can't find version delimiter
prefix = '';
}
const environment = detectEnvironment(prefix, APK_TABS);
// Extract commit ID
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
const commit = commitMatch ? commitMatch[1] : null;
return {
environment,
commit,
isValid: commit !== null,
};
}
```
**Step 4: Run tests to verify they pass**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: All tests pass
**Step 5: Commit**
```bash
git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts
git commit -m "feat: rewrite APK parser to use version as delimiter"
```
---
### Task 3: Update API Route to Pass Version
**Files:**
- Modify: `app/api/apks/route.ts`
- Modify: `app/api/__tests__/apks.test.ts`
**Step 1: Check existing API test**
Read: `app/api/__tests__/apks.test.ts`
**Step 2: Update API route to pass version**
Modify `app/api/apks/route.ts` line 22:
```typescript
// Change from:
const parsed = parseApkFilename(apk.name);
// To:
const parsed = parseApkFilename(apk.name, version);
```
**Step 3: Verify build succeeds**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/apks/route.ts
git commit -m "feat: pass version parameter to APK parser"
```
---
### Task 4: Update ApkList to Use Configured Tabs
**Files:**
- Modify: `lib/constants.ts`
- Modify: `app/download/ApkList.tsx`
- Add: `app/api/config/route.ts`
**Step 1: Create config API endpoint**
Create `app/api/config/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { APK_TABS } from '@/lib/constants';
export async function GET() {
return NextResponse.json({
tabs: APK_TABS,
});
}
```
**Step 2: Update ApkList to fetch and use configured tabs**
Modify `app/download/ApkList.tsx`:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
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;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
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={() => {}}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
false // activeTab logic removed for simplicity
? '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['product'] || []} />
</div>
</div>
);
}
```
Wait, I need to preserve the activeTab state. Let me revise:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
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;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
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 3: Run build to verify**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/config/route.ts app/download/ApkList.tsx
git commit -m "feat: add config API and update ApkList to use configured tabs"
```
---
### Task 5: Final Verification
**Step 1: Run all tests**
Run: `npm test`
Expected: All tests pass
**Step 2: Run build**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Manual test with dev server**
Run: `npm run dev`
Test: Navigate to download page, select project and version, verify tabs display correctly
---
## Summary
| File | Change |
|------|--------|
| `lib/constants.ts` | Add `APK_TABS` env var |
| `lib/apk-parser.ts` | Rewrite parsing logic with version delimiter |
| `lib/__tests__/apk-parser.test.ts` | Update tests |
| `app/api/apks/route.ts` | Pass version to parser |
| `app/api/config/route.ts` | New endpoint for tab config |
| `app/download/ApkList.tsx` | Use configured tabs from API |

View File

@@ -0,0 +1,53 @@
# Download Page 可读性优化设计
## 概述
提高 `download` page 的可读性,解决两个问题:
1. SearchForm 内填写/选择的文字颜色过浅
2. ApkList 的 commit id 列需要显示环境标签
## 修改项
### 1. SearchForm 文字颜色修复
**问题**: 下拉框选中后的文字和输入框内的文字颜色太浅,用户看不清。
**解决方案**: 为 `select``input` 元素添加 `text-gray-900` 类。
**修改文件**: `app/download/SearchForm.tsx`
**改动点**:
- 项目下拉框 (line ~61): 添加 `text-gray-900`
- 版本下拉框 (line ~86): 添加 `text-gray-900`
- Commit ID 输入框 (line ~112): 添加 `text-gray-900`
### 2. ApkList commit id 列显示环境标签
**问题**: 当前 commit id 列只显示 commit hash无法直观看出 APK 的环境。
**解决方案**: 在 commit id 后面添加彩色环境标签。
**修改文件**:
- `app/download/ApkTable.tsx` - 接收并显示 environment
- `app/download/ApkList.tsx` - 传递 environment 属性给 ApkTable
**标签样式**:
| 环境 | 样式 |
|------|------|
| product | 蓝色背景 `bg-blue-100 text-blue-800` |
| dev | 绿色背景 `bg-green-100 text-green-800` |
| sandbox | 绿色背景 `bg-green-100 text-green-800` |
| other | 灰色背景 `bg-gray-100 text-gray-800` |
**显示效果示例**:
```
abc1234 [product]
def5678 [dev]
xyz9012 [other]
```
## 实现步骤
1. 修改 `SearchForm.tsx` - 为 3 个表单控件添加 `text-gray-900`
2. 修改 `ApkList.tsx` - 确保 Apk interface 包含 environment 并传递给 ApkTable
3. 修改 `ApkTable.tsx` - 更新 Apk interface在 commit 列显示环境标签

View File

@@ -0,0 +1,170 @@
# Download Page 可读性优化 实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 提高 download page 的可读性:修复 SearchForm 文字颜色、在 commit id 列显示环境标签
**Architecture:** 直接修改现有组件,为表单控件添加文字颜色类,为 ApkTable 添加环境标签显示
**Tech Stack:** Next.js, React, Tailwind CSS, TypeScript
---
### Task 1: 修复 SearchForm 文字颜色
**Files:**
- Modify: `app/download/SearchForm.tsx:61,86,112`
**Step 1: 为项目下拉框添加文字颜色**
修改 `app/download/SearchForm.tsx` 第 61 行,为项目 select 添加 `text-gray-900`
```tsx
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
```
**Step 2: 为版本下拉框添加文字颜色**
修改第 86 行,为版本 select 添加 `text-gray-900`
```tsx
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 text-gray-900"
```
**Step 3: 为 Commit ID 输入框添加文字颜色**
修改第 112 行,为 input 添加 `text-gray-900`
```tsx
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
```
**Step 4: 验证修改**
Run: `npm run build`
Expected: Build succeeds without errors
**Step 5: 提交**
```bash
git add app/download/SearchForm.tsx
git commit -m "fix: improve SearchForm text visibility with darker color
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
### Task 2: ApkTable 添加环境标签显示
**Files:**
- Modify: `app/download/ApkTable.tsx`
**Step 1: 更新 Apk interface 添加 environment 字段**
修改 `app/download/ApkTable.tsx` 的 Apk interface (约第 5-11 行)
```tsx
import { formatFileSize, formatDateTime } from '@/lib/utils';
import { type ApkEnvironment } from '@/lib/apk-parser';
interface Apk {
name: string;
environment: ApkEnvironment;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
```
**Step 2: 添加环境标签渲染辅助函数**
在 interface 后、组件前添加:
```tsx
function getEnvironmentBadgeClass(env: ApkEnvironment): string {
switch (env) {
case 'product':
return 'bg-blue-100 text-blue-800';
case 'dev':
case 'sandbox':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function formatEnvironment(env: ApkEnvironment): string {
return env;
}
```
**Step 3: 修改 commit id 列显示环境标签**
修改约第 48-52 行的 commit id 单元格:
```tsx
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-gray-900">
{apk.commit || '-'}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${getEnvironmentBadgeClass(apk.environment)}`}>
{formatEnvironment(apk.environment)}
</span>
</div>
</td>
```
**Step 4: 验证修改**
Run: `npm run build`
Expected: Build succeeds without errors
**Step 5: 提交**
```bash
git add app/download/ApkTable.tsx
git commit -m "feat: add environment badge to commit id column in ApkTable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
### Task 3: 验证 ApkList 传递 environment
**Files:**
- Verify: `app/download/ApkList.tsx`
**Step 1: 检查 ApkList 是否已正确传递 environment**
查看 `app/download/ApkList.tsx`
- Apk interface 已包含 `environment: ApkEnvironment` (第 12 行)
- ApkList 传给 ApkTable 的 apks 来自 `groupedApks[activeTab]` (第 101 行)
- 这些 apks 已包含 environment 字段
**结论**: 无需修改environment 已正确传递。
**Step 2: 最终验证**
Run: `npm run build && npm test`
Expected: Build succeeds, all tests pass
**Step 3: 最终提交(如有未提交的更改)**
```bash
git status
# If clean, no action needed
```
---
## 验收标准
1. SearchForm 中选择项目/版本后,文字清晰可见(深灰色)
2. ApkTable 的 commit id 列显示环境标签,如 `abc1234 [product]`
3. 不同环境有不同颜色product=蓝色, dev/sandbox=绿色, other=灰色
4. `npm run build` 成功
5. `npm test` 全部通过

View File

@@ -1,60 +1,49 @@
import { parseApkFilename, detectEnvironment } from '../apk-parser';
import { parseApkFilename, detectEnvironment, getApkTabs } 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');
it('should parse product APK (no environment)', () => {
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('product');
expect(result.commit).toBe('bfeddf2d0');
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');
it('should parse dev APK', () => {
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse sandbox APK', () => {
const result = parseApkFilename('ft_sandbox_0_42_57ef3a60d.apk');
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('sandbox');
expect(result.commit).toBe('57ef3a60d');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should handle invalid format', () => {
const result = parseApkFilename('invalid_filename.apk');
expect(result.environment).toBe('product');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
it('should map unknown environment to other', () => {
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBe('bfeddf2d0');
});
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);
it('should map timeshift to other', () => {
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
expect(result.environment).toBe('other');
});
});
describe('detectEnvironment', () => {
it('should detect dev environment', () => {
expect(detectEnvironment('ft_timeshift_0_42_57ef3a60d.apk')).toBe('dev');
it('should detect configured environment', () => {
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toEqual({ environment: 'dev', rawEnvironment: 'dev' });
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toEqual({ environment: 'sandbox', rawEnvironment: 'sandbox' });
});
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 detect lan environment', () => {
expect(detectEnvironment('ft_lan_0_42_57ef3a60d.apk')).toBe('dev');
});
it('should return other for unknown environments', () => {
expect(detectEnvironment('ft_unknown_0_42_57ef3a60d.apk')).toBe('other');
it('should return other for unconfigured environment', () => {
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'lan' });
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'timeshift' });
});
});

View File

@@ -49,7 +49,6 @@ describe('getProjects', () => {
const result = await getProjects();
expect(mockedFs.readdir).toHaveBeenCalledWith('./resources');
expect(result).toEqual(['project1', 'project2']);
});

View File

@@ -1,42 +1,53 @@
export type ApkEnvironment = 'dev' | 'sandbox' | 'product' | 'other';
import { APK_TABS } from './constants';
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
export interface ParsedApkMetadata {
environment: ApkEnvironment;
rawEnvironment: string;
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 (alphabetic only) between first and second underscores
const match = filename.match(/^ft_([a-z]+)_/i);
if (!match) {
// No environment suffix, default to product
return 'product';
}
const env = match[1].toLowerCase();
return ENVIRONMENT_MAP[env] || 'other';
export function getApkTabs(): string[] {
return APK_TABS;
}
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;
export function detectEnvironment(prefix: string, configuredTabs: string[]): { environment: ApkEnvironment; rawEnvironment: string } {
// Empty prefix → other
if (!prefix) {
return { environment: 'other', rawEnvironment: 'other' };
}
// No underscore means product (e.g., "ft")
if (!prefix.includes('_')) {
return { environment: 'product', rawEnvironment: 'product' };
}
// Extract environment from last segment after underscore
const lastUnderscoreIndex = prefix.lastIndexOf('_');
const rawEnv = prefix.substring(lastUnderscoreIndex + 1);
// Check if environment is configured
if (configuredTabs.includes(rawEnv)) {
return { environment: rawEnv as ApkEnvironment, rawEnvironment: rawEnv };
}
return { environment: 'other', rawEnvironment: rawEnv };
}
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
// Use version as delimiter (with leading underscore)
const delimiter = `_${version}_`;
const filenameWithoutExt = filename.replace(/\.apk$/, '');
const parts = filenameWithoutExt.split(delimiter);
const { environment, rawEnvironment } = detectEnvironment(parts[0], APK_TABS);
const commit = parts[1].substring(0, 9);
return {
environment,
rawEnvironment,
commit,
isValid,
isValid: commit !== null,
};
}