refactor: organize tests and extract formatting utilities
- Move test files to lib/__tests__/ directory - Extract formatting utilities (formatVersion, formatFileSize, formatDateTime) from fs-utils.ts to new utils.ts module - Add Jest test configuration and test scripts - Update component imports to use new utils module - Add CLAUDE.md documentation for project structure Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
FTDL (Resource Download Website) is a Next.js-based internal network APK resource download tool. It provides a web interface for searching and downloading Android APK files organized by projects and versions.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
npm run test:watch
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required environment variables (create in `.env.local`):
|
||||||
|
|
||||||
|
- `AUTH_USERNAME` - Login username (default: `admin`)
|
||||||
|
- `AUTH_PASSWORD` - Login password (default: `changeme`)
|
||||||
|
- `RESOURCE_PATH` - Path to APK storage directory
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ftdl/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── api/ # API routes
|
||||||
|
│ ├── download/ # Main download page
|
||||||
|
│ ├── login/ # Login page
|
||||||
|
│ └── layout.tsx # Root layout
|
||||||
|
├── lib/ # Shared utilities and constants
|
||||||
|
│ ├── __tests__/ # Test files for lib modules
|
||||||
|
│ ├── constants.ts # Environment variables and config
|
||||||
|
│ ├── fs-utils.ts # File system operations with security
|
||||||
|
│ └── utils.ts # Helper functions (formatting)
|
||||||
|
├── public/ # Static assets
|
||||||
|
├── middleware.ts # Authentication middleware
|
||||||
|
└── *.config.* # Configuration files (Jest, Next.js, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Security Layer
|
||||||
|
|
||||||
|
All file system operations go through `lib/fs-utils.ts` which implements `validatePath()` to prevent path traversal attacks. The middleware (middleware.ts) protects all routes except `/login` using a simple cookie-based authentication.
|
||||||
|
|
||||||
|
### File System Abstraction
|
||||||
|
|
||||||
|
The application expects this resource directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
RESOURCE_PATH/
|
||||||
|
├── PROJECT_NAME/
|
||||||
|
│ ├── VERSION_NAME/
|
||||||
|
│ │ └── apks/
|
||||||
|
│ │ └── *.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
- Version names use underscores (e.g., `0_42`) but display with dots (`0.42`)
|
||||||
|
- APK filenames can contain commit IDs which are extracted via regex `([a-f0-9]{7,})` for filtering
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
Located in `app/api/`:
|
||||||
|
- `/api/projects` - List all project directories
|
||||||
|
- `/api/versions?project={name}` - List versions for a project
|
||||||
|
- `/api/apks?project={name}&version={name}&commitId={id}` - List APK files with optional commit filtering
|
||||||
|
- `/api/download?project={name}&version={name}&apk={name}` - Stream APK file for download
|
||||||
|
- `/api/login` - Handle authentication (sets cookie)
|
||||||
|
|
||||||
|
### Data Fetching
|
||||||
|
|
||||||
|
Client-side uses SWR for data fetching in the download flow:
|
||||||
|
1. User selects project → fetch projects
|
||||||
|
2. User selects version → fetch versions for that project
|
||||||
|
3. Optional commit filter → fetch filtered APKs
|
||||||
|
4. Download button triggers streaming download via API
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
- `lib/fs-utils.ts` - Core file system utilities with path validation
|
||||||
|
- `lib/constants.ts` - Environment variable defaults and cookie configuration
|
||||||
|
- `middleware.ts` - Route protection via Next.js middleware
|
||||||
|
- `app/download/page.tsx` - Main UI with search form and APK results
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// app/download/ApkCard.tsx
|
// app/download/ApkCard.tsx
|
||||||
import { formatFileSize, formatDateTime } from '@/lib/fs-utils';
|
import { formatFileSize, formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
interface ApkCardProps {
|
interface ApkCardProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { formatVersion } from '@/lib/fs-utils';
|
import { formatVersion } from '@/lib/utils';
|
||||||
|
|
||||||
const fetcher = async (url: string) => {
|
const fetcher = async (url: string) => {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|||||||
28
jest.config.js
Normal file
28
jest.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const nextJest = require('next/jest')
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||||
|
dir: './',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add any custom config to be passed to Jest
|
||||||
|
const customJestConfig = {
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
|
},
|
||||||
|
testMatch: [
|
||||||
|
'**/__tests__/**/*.test.{ts,tsx,js,jsx}',
|
||||||
|
'**/*.test.{ts,tsx,js,jsx}',
|
||||||
|
],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'lib/**/*.{ts,tsx}',
|
||||||
|
'app/**/*.{ts,tsx}',
|
||||||
|
'!app/**/*.stories.{ts,tsx}',
|
||||||
|
'!app/layout.{ts,tsx}',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||||
|
module.exports = createJestConfig(customJestConfig)
|
||||||
2
jest.setup.js
Normal file
2
jest.setup.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
221
lib/__tests__/fs-utils.test.ts
Normal file
221
lib/__tests__/fs-utils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { validatePath, getProjects, getVersions, getApks } from '../fs-utils';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
|
// Mock fs module
|
||||||
|
jest.mock('fs/promises', () => ({
|
||||||
|
readdir: jest.fn(),
|
||||||
|
stat: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||||
|
|
||||||
|
describe('validatePath', () => {
|
||||||
|
test('应该验证正常路径', () => {
|
||||||
|
expect(validatePath('/base', 'dir')).toBe('/base/dir');
|
||||||
|
expect(validatePath('/base', 'dir', 'subdir')).toBe('/base/dir/subdir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该解析相对路径', () => {
|
||||||
|
expect(validatePath('/base', './dir')).toBe('/base/dir');
|
||||||
|
expect(validatePath('/base/../base', 'dir')).toBe('/base/dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该检测并拒绝路径遍历攻击', () => {
|
||||||
|
expect(() => validatePath('/base', '../etc')).toThrow('Invalid path: path traversal detected');
|
||||||
|
expect(() => validatePath('/base', 'dir', '../../etc')).toThrow('Invalid path: path traversal detected');
|
||||||
|
expect(() => validatePath('/base', '../base')).not.toThrow(); // 同级目录应被允许
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理特殊字符的路径', () => {
|
||||||
|
expect(validatePath('/base', 'dir with spaces')).toBe('/base/dir with spaces');
|
||||||
|
expect(validatePath('/base', 'dir-with-dashes')).toBe('/base/dir-with-dashes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProjects', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该返回项目列表', async () => {
|
||||||
|
const mockDirs = ['project1', 'project2', 'not-a-dir.txt'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockDirs as any);
|
||||||
|
|
||||||
|
// Mock stat for each directory
|
||||||
|
mockedFs.stat
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true } as any)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true } as any)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => false } as any);
|
||||||
|
|
||||||
|
const result = await getProjects();
|
||||||
|
|
||||||
|
expect(mockedFs.readdir).toHaveBeenCalledWith('./resources');
|
||||||
|
expect(result).toEqual(['project1', 'project2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理读取目录错误', async () => {
|
||||||
|
mockedFs.readdir.mockRejectedValue(new Error('Permission denied'));
|
||||||
|
|
||||||
|
await expect(getProjects()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理单个文件的状态检查错误', async () => {
|
||||||
|
mockedFs.readdir.mockResolvedValue(['project1', 'project2']);
|
||||||
|
|
||||||
|
mockedFs.stat
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true } as any)
|
||||||
|
.mockRejectedValueOnce(new Error('ENOENT'));
|
||||||
|
|
||||||
|
const result = await getProjects();
|
||||||
|
|
||||||
|
expect(result).toEqual(['project1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVersions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该返回指定项目的版本列表并排序', async () => {
|
||||||
|
const mockVersions = ['1_0', '0_42', '2_1'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockVersions as any);
|
||||||
|
|
||||||
|
mockedFs.stat.mockResolvedValue({ isDirectory: () => true } as any);
|
||||||
|
|
||||||
|
const result = await getVersions('test-project');
|
||||||
|
|
||||||
|
expect(mockedFs.readdir).toHaveBeenCalledWith(expect.stringContaining('test-project'));
|
||||||
|
expect(result).toEqual(['0_42', '1_0', '2_1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该过滤掉非目录文件', async () => {
|
||||||
|
const mockVersions = ['1_0', 'readme.txt', '2_1'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockVersions as any);
|
||||||
|
|
||||||
|
mockedFs.stat
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true } as any)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => false } as any)
|
||||||
|
.mockResolvedValueOnce({ isDirectory: () => true } as any);
|
||||||
|
|
||||||
|
const result = await getVersions('test-project');
|
||||||
|
|
||||||
|
expect(result).toEqual(['1_0', '2_1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getApks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该返回指定版本的 APK 列表并按修改时间排序', async () => {
|
||||||
|
const mockFiles = ['app-v1.apk', 'app-v2.apk', 'readme.txt'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockFiles as any);
|
||||||
|
|
||||||
|
const baseTime = new Date('2024-01-01T00:00:00');
|
||||||
|
mockedFs.stat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 1024 * 1024,
|
||||||
|
mtime: baseTime,
|
||||||
|
} as any)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 2 * 1024 * 1024,
|
||||||
|
mtime: new Date('2024-01-02T00:00:00'),
|
||||||
|
} as any)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 100,
|
||||||
|
mtime: new Date('2024-01-03T00:00:00'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].name).toBe('app-v2.apk');
|
||||||
|
expect(result[1].name).toBe('app-v1.apk');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该从文件名中提取 commit id', async () => {
|
||||||
|
const mockFiles = ['app-abc1234567.apk', 'app.apk'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockFiles as any);
|
||||||
|
|
||||||
|
mockedFs.stat.mockResolvedValue({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 1024 * 1024,
|
||||||
|
mtime: new Date('2024-01-01T00:00:00'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].commit).toBe('abc1234567');
|
||||||
|
expect(result[1].commit).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该按 commitId 过滤 APK', async () => {
|
||||||
|
const mockFiles = ['app-abc1234567.apk', 'app-def4567890.apk'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockFiles as any);
|
||||||
|
|
||||||
|
const time1 = new Date('2024-01-01T00:00:00');
|
||||||
|
const time2 = new Date('2024-01-02T00:00:00');
|
||||||
|
mockedFs.stat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 1024 * 1024,
|
||||||
|
mtime: time1,
|
||||||
|
} as any)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 2 * 1024 * 1024,
|
||||||
|
mtime: time2,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0', 'abc123');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('app-abc1234567.apk');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该忽略 commitId 的大小写', async () => {
|
||||||
|
const mockFiles = ['app-ABC1234567.apk'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockFiles as any);
|
||||||
|
|
||||||
|
mockedFs.stat.mockResolvedValue({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 1024 * 1024,
|
||||||
|
mtime: new Date('2024-01-01T00:00:00'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0', 'abc');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].commit).toBe('ABC1234567');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('当 apks 目录不存在时应该返回空数组', async () => {
|
||||||
|
mockedFs.readdir.mockRejectedValue(new Error('ENOENT'));
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该只返回 .apk 文件', async () => {
|
||||||
|
const mockFiles = ['app.apk', 'readme.txt', 'config.json', 'another.apk'];
|
||||||
|
mockedFs.readdir.mockResolvedValue(mockFiles as any);
|
||||||
|
|
||||||
|
mockedFs.stat.mockResolvedValue({
|
||||||
|
isDirectory: () => false,
|
||||||
|
size: 1024 * 1024,
|
||||||
|
mtime: new Date('2024-01-01T00:00:00'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await getApks('test-project', '1_0');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.every(apk => apk.name.endsWith('.apk'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
lib/__tests__/utils.test.ts
Normal file
65
lib/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { formatVersion, formatFileSize, formatDateTime } from '../utils';
|
||||||
|
|
||||||
|
describe('formatVersion', () => {
|
||||||
|
test('应该将下划线替换为点号', () => {
|
||||||
|
expect(formatVersion('0_42')).toBe('0.42');
|
||||||
|
expect(formatVersion('1_23_4')).toBe('1.23.4');
|
||||||
|
expect(formatVersion('2_0')).toBe('2.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理没有下划线的版本号', () => {
|
||||||
|
expect(formatVersion('1.0')).toBe('1.0');
|
||||||
|
expect(formatVersion('2.3.4')).toBe('2.3.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该处理空字符串', () => {
|
||||||
|
expect(formatVersion('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFileSize', () => {
|
||||||
|
test('应该格式化字节数', () => {
|
||||||
|
expect(formatFileSize(0)).toBe('0 B');
|
||||||
|
expect(formatFileSize(100)).toBe('100 B');
|
||||||
|
expect(formatFileSize(1023)).toBe('1023 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该格式化KB', () => {
|
||||||
|
expect(formatFileSize(1024)).toBe('1.00 KB');
|
||||||
|
expect(formatFileSize(2048)).toBe('2.00 KB');
|
||||||
|
expect(formatFileSize(1536)).toBe('1.50 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该格式化MB', () => {
|
||||||
|
expect(formatFileSize(1024 * 1024)).toBe('1.00 MB');
|
||||||
|
expect(formatFileSize(2 * 1024 * 1024)).toBe('2.00 MB');
|
||||||
|
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.50 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该格式化GB', () => {
|
||||||
|
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.00 GB');
|
||||||
|
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.00 GB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateTime', () => {
|
||||||
|
test('应该格式化日期时间为标准格式', () => {
|
||||||
|
const date = new Date('2024-01-15T09:30:00');
|
||||||
|
expect(formatDateTime(date)).toBe('2024-01-15 09:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该正确处理午夜时间', () => {
|
||||||
|
const date = new Date('2024-12-31T00:00:00');
|
||||||
|
expect(formatDateTime(date)).toBe('2024-12-31 00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该正确处理月末日期', () => {
|
||||||
|
const date = new Date('2024-12-31T23:59:00');
|
||||||
|
expect(formatDateTime(date)).toBe('2024-12-31 23:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该正确处理单数月份和日期', () => {
|
||||||
|
const date = new Date('2024-01-05T09:05:00');
|
||||||
|
expect(formatDateTime(date)).toBe('2024-01-05 09:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,31 +101,3 @@ export async function getApks(project: string, version: string, commitId?: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化版本号显示 (0_42 -> 0.42)
|
|
||||||
*/
|
|
||||||
export function formatVersion(version: string): string {
|
|
||||||
return version.replace('_', '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化文件大小
|
|
||||||
*/
|
|
||||||
export function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
||||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化日期时间
|
|
||||||
*/
|
|
||||||
export function formatDateTime(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|||||||
31
lib/utils.ts
Normal file
31
lib/utils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// lib/utils.ts
|
||||||
|
// Client-safe utility functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化版本号显示 (0_42 -> 0.42)
|
||||||
|
*/
|
||||||
|
export function formatVersion(version: string): string {
|
||||||
|
return version.replace(/_/g, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期时间
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
5404
package-lock.json
generated
5404
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -5,7 +5,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -15,9 +18,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user