Compare commits
10 Commits
10fa6782e6
...
d1ad58b504
| Author | SHA1 | Date | |
|---|---|---|---|
| d1ad58b504 | |||
| 8d7d2f37d7 | |||
| 31bbebc2ab | |||
| 0b86887680 | |||
| d22aa276e8 | |||
| ddf2729426 | |||
| 1eba2f5f39 | |||
| 7268b91ed3 | |||
| 0b817984bb | |||
| df4daea1c7 |
12
.env.example
12
.env.example
@@ -1,3 +1,9 @@
|
|||||||
AUTH_USERNAME=
|
# Authentication credentials (change in production!)
|
||||||
AUTH_PASSWORD=
|
AUTH_USERNAME=admin
|
||||||
RESOURCE_PATH=
|
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
3
.gitignore
vendored
@@ -40,3 +40,6 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# dumy files for download tests
|
||||||
|
.resources
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const apks = await getApks(project, version, commitId);
|
const apks = await getApks(project, version, commitId);
|
||||||
const apksWithMetadata = apks.map(apk => {
|
const apksWithMetadata = apks.map(apk => {
|
||||||
const parsed = parseApkFilename(apk.name);
|
const parsed = parseApkFilename(apk.name, version);
|
||||||
return {
|
return {
|
||||||
name: apk.name,
|
name: apk.name,
|
||||||
size: apk.size,
|
size: apk.size,
|
||||||
environment: parsed.environment,
|
environment: parsed.environment,
|
||||||
|
rawEnvironment: parsed.rawEnvironment,
|
||||||
commit: parsed.commit,
|
commit: parsed.commit,
|
||||||
modifiedAt: apk.modifiedAt.toISOString(),
|
modifiedAt: apk.modifiedAt.toISOString(),
|
||||||
downloadUrl: `/api/download?project=${encodeURIComponent(project)}&version=${encodeURIComponent(version)}&filename=${encodeURIComponent(apk.name)}`,
|
downloadUrl: `/api/download?project=${encodeURIComponent(project)}&version=${encodeURIComponent(version)}&filename=${encodeURIComponent(apk.name)}`,
|
||||||
|
|||||||
8
app/api/config/route.ts
Normal file
8
app/api/config/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { type ApkEnvironment } from '@/lib/apk-parser';
|
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||||
import ApkTable from './ApkTable';
|
import ApkTable from './ApkTable';
|
||||||
@@ -10,6 +10,7 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
|||||||
interface Apk {
|
interface Apk {
|
||||||
name: string;
|
name: string;
|
||||||
environment: ApkEnvironment;
|
environment: ApkEnvironment;
|
||||||
|
rawEnvironment: string;
|
||||||
commit: string | null;
|
commit: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
@@ -22,16 +23,26 @@ interface ApkListProps {
|
|||||||
commitId: string;
|
commitId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: ApkEnvironment[] = ['dev', 'product', 'sandbox', 'other'];
|
|
||||||
|
|
||||||
export default function ApkList({ project, version, commitId }: ApkListProps) {
|
export default function ApkList({ project, version, commitId }: ApkListProps) {
|
||||||
|
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
|
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[]>(
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build tabs: product -> configured -> other
|
||||||
|
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { formatFileSize, formatDateTime } from '@/lib/utils';
|
import { formatFileSize, formatDateTime } from '@/lib/utils';
|
||||||
|
import { type ApkEnvironment } from '@/lib/apk-parser';
|
||||||
|
|
||||||
interface Apk {
|
interface Apk {
|
||||||
name: string;
|
name: string;
|
||||||
|
environment: ApkEnvironment;
|
||||||
|
rawEnvironment: string;
|
||||||
commit: string | null;
|
commit: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
@@ -14,6 +17,18 @@ interface ApkTableProps {
|
|||||||
apks: Apk[];
|
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) {
|
export default function ApkTable({ apks }: ApkTableProps) {
|
||||||
if (!apks || apks.length === 0) {
|
if (!apks || apks.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +61,14 @@ export default function ApkTable({ apks }: ApkTableProps) {
|
|||||||
{apks.map((apk) => (
|
{apks.map((apk) => (
|
||||||
<tr key={apk.name} className="hover:bg-gray-50">
|
<tr key={apk.name} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="font-mono text-sm text-gray-900">
|
<div className="flex items-center gap-2">
|
||||||
{apk.commit || '-'}
|
<span className="font-mono text-sm text-gray-900">
|
||||||
</span>
|
{apk.commit || '-'}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded ${getEnvironmentBadgeClass(apk.environment)}`}>
|
||||||
|
{apk.rawEnvironment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{formatFileSize(apk.size)}
|
{formatFileSize(apk.size)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
|||||||
setSelectedProject(e.target.value);
|
setSelectedProject(e.target.value);
|
||||||
setSelectedVersion('');
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">选择项目</option>
|
<option value="">选择项目</option>
|
||||||
@@ -83,7 +83,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
|||||||
value={selectedVersion}
|
value={selectedVersion}
|
||||||
onChange={(e) => setSelectedVersion(e.target.value)}
|
onChange={(e) => setSelectedVersion(e.target.value)}
|
||||||
disabled={!selectedProject}
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">选择版本</option>
|
<option value="">选择版本</option>
|
||||||
@@ -109,7 +109,7 @@ export default function SearchForm({ onSearch }: SearchFormProps) {
|
|||||||
value={commitId}
|
value={commitId}
|
||||||
onChange={(e) => setCommitId(e.target.value)}
|
onChange={(e) => setCommitId(e.target.value)}
|
||||||
placeholder="输入 commit id..."
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
import React from 'react';
|
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 ApkList from '../ApkList';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
jest.mock('swr');
|
jest.mock('swr');
|
||||||
|
|
||||||
|
// Mock fetch for config API
|
||||||
|
const mockFetch = jest.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
describe('ApkList', () => {
|
describe('ApkList', () => {
|
||||||
const mockApks = [
|
const mockApks = [
|
||||||
{
|
{
|
||||||
name: 'ft_timeshift_0_42_57ef3a60d.apk',
|
name: 'ft_dev_0_42_57ef3a60d.apk',
|
||||||
environment: 'dev',
|
environment: 'dev',
|
||||||
|
rawEnvironment: 'dev',
|
||||||
commit: '57ef3a60d',
|
commit: '57ef3a60d',
|
||||||
size: 15728640,
|
size: 15728640,
|
||||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
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',
|
name: 'ft_0_42_ff9ff3441.apk',
|
||||||
environment: 'product',
|
environment: 'product',
|
||||||
|
rawEnvironment: 'product',
|
||||||
commit: 'ff9ff3441',
|
commit: 'ff9ff3441',
|
||||||
size: 15728640,
|
size: 15728640,
|
||||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
modifiedAt: '2026-03-04T14:30:00.000Z',
|
||||||
downloadUrl: '/api/download?project=FT&version=0_42&filename=ft_0_42_ff9ff3441.apk',
|
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',
|
environment: 'sandbox',
|
||||||
commit: '57ef3a60d',
|
rawEnvironment: 'sandbox',
|
||||||
|
commit: 'abc123456',
|
||||||
size: 15728640,
|
size: 15728640,
|
||||||
modifiedAt: '2026-03-04T14:30:00.000Z',
|
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,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
// Mock config API response
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ tabs: ['dev', 'sandbox'] }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render all 4 tabs', () => {
|
afterEach(() => {
|
||||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
mockFetch.mockClear();
|
||||||
|
|
||||||
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', () => {
|
it('should render all 4 tabs', async () => {
|
||||||
render(<ApkList project="FT" version="0_42" commitId="" />);
|
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/);
|
const devTab = screen.getByText(/dev/);
|
||||||
fireEvent.click(devTab);
|
fireEvent.click(devTab);
|
||||||
@@ -59,26 +94,30 @@ describe('ApkList', () => {
|
|||||||
expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
|
expect(screen.getByText('57ef3a60d')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading state', () => {
|
it('should show loading state', async () => {
|
||||||
(useSWR as jest.Mock).mockReturnValue({
|
(useSWR as jest.Mock).mockReturnValue({
|
||||||
data: null,
|
data: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
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();
|
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show error state', () => {
|
it('should show error state', async () => {
|
||||||
(useSWR as jest.Mock).mockReturnValue({
|
(useSWR as jest.Mock).mockReturnValue({
|
||||||
data: null,
|
data: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: new Error('API error'),
|
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();
|
expect(screen.getByText('加载失败,请重试')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
532
docs/plans/2026-03-04-apk-parsing-fix.md
Normal file
532
docs/plans/2026-03-04-apk-parsing-fix.md
Normal 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 |
|
||||||
53
docs/plans/2026-03-05-download-page-readability-design.md
Normal file
53
docs/plans/2026-03-05-download-page-readability-design.md
Normal 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 列显示环境标签
|
||||||
170
docs/plans/2026-03-05-download-page-readability.md
Normal file
170
docs/plans/2026-03-05-download-page-readability.md
Normal 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` 全部通过
|
||||||
@@ -1,60 +1,49 @@
|
|||||||
import { parseApkFilename, detectEnvironment } from '../apk-parser';
|
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
|
||||||
|
|
||||||
describe('parseApkFilename', () => {
|
describe('parseApkFilename', () => {
|
||||||
it('should parse APK with environment suffix', () => {
|
it('should parse product APK (no environment)', () => {
|
||||||
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk');
|
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
|
||||||
expect(result.environment).toBe('dev');
|
expect(result.environment).toBe('product');
|
||||||
expect(result.commit).toBe('57ef3a60d');
|
expect(result.commit).toBe('bfeddf2d0');
|
||||||
expect(result.isValid).toBe(true);
|
expect(result.isValid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse APK without environment (product)', () => {
|
it('should parse dev APK', () => {
|
||||||
const result = parseApkFilename('ft_0_42_ff9ff3441.apk');
|
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
|
||||||
expect(result.environment).toBe('product');
|
expect(result.environment).toBe('dev');
|
||||||
expect(result.commit).toBe('ff9ff3441');
|
expect(result.commit).toBe('bfeddf2d0');
|
||||||
expect(result.isValid).toBe(true);
|
expect(result.isValid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse sandbox APK', () => {
|
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.environment).toBe('sandbox');
|
||||||
expect(result.commit).toBe('57ef3a60d');
|
expect(result.commit).toBe('bfeddf2d0');
|
||||||
expect(result.isValid).toBe(true);
|
expect(result.isValid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle invalid format', () => {
|
it('should map unknown environment to other', () => {
|
||||||
const result = parseApkFilename('invalid_filename.apk');
|
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
|
||||||
expect(result.environment).toBe('product');
|
expect(result.environment).toBe('other');
|
||||||
expect(result.commit).toBeNull();
|
expect(result.commit).toBe('bfeddf2d0');
|
||||||
expect(result.isValid).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing commit ID', () => {
|
it('should map timeshift to other', () => {
|
||||||
const result = parseApkFilename('ft_dev_0_42.apk');
|
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
|
||||||
expect(result.environment).toBe('dev');
|
expect(result.environment).toBe('other');
|
||||||
expect(result.commit).toBeNull();
|
|
||||||
expect(result.isValid).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectEnvironment', () => {
|
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', () => {
|
it('should return other for unconfigured environment', () => {
|
||||||
expect(detectEnvironment('ft_sandbox_0_42_57ef3a60d.apk')).toBe('sandbox');
|
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'lan' });
|
||||||
});
|
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toEqual({ environment: 'other', rawEnvironment: 'timeshift' });
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ describe('getProjects', () => {
|
|||||||
|
|
||||||
const result = await getProjects();
|
const result = await getProjects();
|
||||||
|
|
||||||
expect(mockedFs.readdir).toHaveBeenCalledWith('./resources');
|
|
||||||
expect(result).toEqual(['project1', 'project2']);
|
expect(result).toEqual(['project1', 'project2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export interface ParsedApkMetadata {
|
||||||
environment: ApkEnvironment;
|
environment: ApkEnvironment;
|
||||||
|
rawEnvironment: string;
|
||||||
commit: string | null;
|
commit: string | null;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment mapping based on filename patterns
|
export function getApkTabs(): string[] {
|
||||||
const ENVIRONMENT_MAP: Record<string, ApkEnvironment> = {
|
return APK_TABS;
|
||||||
'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 parseApkFilename(filename: string): ParsedApkMetadata {
|
export function detectEnvironment(prefix: string, configuredTabs: string[]): { environment: ApkEnvironment; rawEnvironment: string } {
|
||||||
const environment = detectEnvironment(filename);
|
// Empty prefix → other
|
||||||
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
|
if (!prefix) {
|
||||||
const commit = commitMatch ? commitMatch[1] : null;
|
return { environment: 'other', rawEnvironment: 'other' };
|
||||||
const isValid = commit !== null;
|
}
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
environment,
|
environment,
|
||||||
|
rawEnvironment,
|
||||||
commit,
|
commit,
|
||||||
isValid,
|
isValid: commit !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user