feat: 新增核心 API 客户端、认证服务(含令牌刷新和 GitHub OAuth)、图片管理功能及相关 UI 组件和测试。

This commit is contained in:
2026-01-26 09:24:04 +08:00
parent 3c39437af6
commit a4769bc610
8 changed files with 27 additions and 27 deletions

View File

@@ -35,7 +35,7 @@ export const ImageAttachment: React.FC<ImageAttachmentProps> = ({
const handleAddClick = () => { const handleAddClick = () => {
if (disabled) return; if (disabled) return;
// Check if we can add more images // Check if we can add more images
const check = canAddMoreImages(images.length); const check = canAddMoreImages(images.length);
if (!check.canAdd) { if (!check.canAdd) {
@@ -105,11 +105,11 @@ export const ImageAttachment: React.FC<ImageAttachmentProps> = ({
onClick={() => handleImageClick(index)} onClick={() => handleImageClick(index)}
> >
<img <img
src={`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'}/images/${image.id}`} src={`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1'}/images/${image.id}`}
alt={image.fileName} alt={image.fileName}
className="image-attachment__thumbnail" className="image-attachment__thumbnail"
/> />
{/* Delete button overlay */} {/* Delete button overlay */}
{!disabled && ( {!disabled && (
<button <button
@@ -143,7 +143,7 @@ export const ImageAttachment: React.FC<ImageAttachmentProps> = ({
<span className="image-attachment__count"> <span className="image-attachment__count">
{images.length} / {IMAGE_CONSTRAINTS.maxImages} {images.length} / {IMAGE_CONSTRAINTS.maxImages}
</span> </span>
{isApproachingLimit && ( {isApproachingLimit && (
<span className="image-attachment__warning"> <span className="image-attachment__warning">
<Icon icon="mdi:alert" width="16" /> <Icon icon="mdi:alert" width="16" />

View File

@@ -152,7 +152,7 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({
} }
const currentImage = images[currentIndex]; const currentImage = images[currentIndex];
const imageUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'}/images/${currentImage.id}`; const imageUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1'}/images/${currentImage.id}`;
return ( return (
<div <div

View File

@@ -8,7 +8,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from '../services/authService'; import { getAccessToken, getRefreshToken, setTokens, clearTokens } from '../services/authService';
import type { TokenPair } from '../services/authService'; import type { TokenPair } from '../services/authService';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1';
// Refresh token 5 minutes before expiration // Refresh token 5 minutes before expiration
const REFRESH_BUFFER_MS = 5 * 60 * 1000; const REFRESH_BUFFER_MS = 5 * 60 * 1000;

View File

@@ -4,7 +4,7 @@
* Validates: Requirements 12.3, 12.4 * Validates: Requirements 12.3, 12.4
*/ */
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1';
// Token storage keys // Token storage keys
const ACCESS_TOKEN_KEY = 'access_token'; const ACCESS_TOKEN_KEY = 'access_token';

View File

@@ -134,7 +134,7 @@ export function logout(): void {
* Validates: Requirements 13.1 * Validates: Requirements 13.1
*/ */
export function getGitHubLoginUrl(state?: string): string { export function getGitHubLoginUrl(state?: string): string {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1';
const url = new URL(`${baseUrl}/auth/github`); const url = new URL(`${baseUrl}/auth/github`);
if (state) { if (state) {
url.searchParams.append('state', state); url.searchParams.append('state', state);

View File

@@ -224,14 +224,14 @@ describe('imageService', () => {
it('should use API base URL from environment or default', () => { it('should use API base URL from environment or default', () => {
const url = getImageUrl(456); const url = getImageUrl(456);
expect(url).toMatch(/^http:\/\/localhost:8080\/api\/v1\/images\/456$/); expect(url).toMatch(/^http:\/\/localhost:2612\/api\/v1\/images\/456$/);
}); });
}); });
describe('API calls', () => { describe('API calls', () => {
beforeEach(() => { beforeEach(() => {
// Mock fetch // Mock fetch
global.fetch = vi.fn(); globalThis.fetch = vi.fn();
// Mock localStorage // Mock localStorage
Storage.prototype.getItem = vi.fn(() => 'mock-token'); Storage.prototype.getItem = vi.fn(() => 'mock-token');
}); });
@@ -254,7 +254,7 @@ describe('imageService', () => {
}, },
}; };
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: true, ok: true,
json: async () => mockResponse, json: async () => mockResponse,
}); });
@@ -265,7 +265,7 @@ describe('imageService', () => {
// Mock compressImage to return the file as-is (high quality) // Mock compressImage to return the file as-is (high quality)
const result = await uploadImage(123, file, 'high'); const result = await uploadImage(123, file, 'high');
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/transactions/123/images?compression=high'), expect.stringContaining('/transactions/123/images?compression=high'),
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
@@ -288,7 +288,7 @@ describe('imageService', () => {
}); });
it('should throw error when upload fails', async () => { it('should throw error when upload fails', async () => {
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: false, ok: false,
status: 400, status: 400,
json: async () => ({ error: 'Upload failed' }), json: async () => ({ error: 'Upload failed' }),
@@ -324,14 +324,14 @@ describe('imageService', () => {
}, },
]; ];
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ data: mockImages }), json: async () => ({ data: mockImages }),
}); });
const result = await getTransactionImages(123); const result = await getTransactionImages(123);
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/transactions/123/images'), expect.stringContaining('/transactions/123/images'),
expect.objectContaining({ expect.objectContaining({
method: 'GET', method: 'GET',
@@ -345,7 +345,7 @@ describe('imageService', () => {
}); });
it('should throw error when fetch fails', async () => { it('should throw error when fetch fails', async () => {
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: false, ok: false,
status: 404, status: 404,
json: async () => ({ error: 'Transaction not found' }), json: async () => ({ error: 'Transaction not found' }),
@@ -357,14 +357,14 @@ describe('imageService', () => {
describe('deleteImage', () => { describe('deleteImage', () => {
it('should delete image successfully', async () => { it('should delete image successfully', async () => {
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({}), json: async () => ({}),
}); });
await deleteImage(123, 456); await deleteImage(123, 456);
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/transactions/123/images/456'), expect.stringContaining('/transactions/123/images/456'),
expect.objectContaining({ expect.objectContaining({
method: 'DELETE', method: 'DELETE',
@@ -376,7 +376,7 @@ describe('imageService', () => {
}); });
it('should throw error when delete fails', async () => { it('should throw error when delete fails', async () => {
(global.fetch as any).mockResolvedValueOnce({ (globalThis.fetch as any).mockResolvedValueOnce({
ok: false, ok: false,
status: 404, status: 404,
json: async () => ({ error: 'Image not found' }), json: async () => ({ error: 'Image not found' }),

View File

@@ -6,7 +6,7 @@
import type { TransactionImage, UserSettings } from '../types'; import type { TransactionImage, UserSettings } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1';
// Token storage key // Token storage key
const ACCESS_TOKEN_KEY = 'access_token'; const ACCESS_TOKEN_KEY = 'access_token';

View File

@@ -6,7 +6,7 @@
import api from './api'; import api from './api';
import type { CurrencyCode } from '../types'; import type { CurrencyCode } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2612/api/v1';
// Report API Response Types // Report API Response Types
export interface CurrencySummary { export interface CurrencySummary {
@@ -94,7 +94,7 @@ export const getTransactionSummary = async (
params: SummaryParams params: SummaryParams
): Promise<TransactionSummaryResponse> => { ): Promise<TransactionSummaryResponse> => {
const response = await api.get<{ success: boolean; data: TransactionSummaryResponse }>( const response = await api.get<{ success: boolean; data: TransactionSummaryResponse }>(
'/reports/summary', '/reports/summary',
params as unknown as Record<string, string | number | boolean | undefined> params as unknown as Record<string, string | number | boolean | undefined>
); );
if (!response.data) { if (!response.data) {
@@ -110,7 +110,7 @@ export const getCategorySummary = async (
params: CategoryParams params: CategoryParams
): Promise<CategorySummaryResponse> => { ): Promise<CategorySummaryResponse> => {
const response = await api.get<{ success: boolean; data: CategorySummaryResponse }>( const response = await api.get<{ success: boolean; data: CategorySummaryResponse }>(
'/reports/category', '/reports/category',
params as unknown as Record<string, string | number | boolean | undefined> params as unknown as Record<string, string | number | boolean | undefined>
); );
if (!response.data) { if (!response.data) {
@@ -126,7 +126,7 @@ export const getTrendData = async (
params: TrendParams params: TrendParams
): Promise<TrendDataResponse> => { ): Promise<TrendDataResponse> => {
const response = await api.get<{ success: boolean; data: TrendDataResponse }>( const response = await api.get<{ success: boolean; data: TrendDataResponse }>(
'/reports/trend', '/reports/trend',
params as unknown as Record<string, string | number | boolean | undefined> params as unknown as Record<string, string | number | boolean | undefined>
); );
if (!response.data) { if (!response.data) {
@@ -163,15 +163,15 @@ export const exportReport = async (params: ExportParams): Promise<void> => {
// Create a temporary anchor element and trigger download // Create a temporary anchor element and trigger download
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
// Generate filename based on date range and format // Generate filename based on date range and format
const filename = `report_${params.start_date.replace(/-/g, '')}_to_${params.end_date.replace(/-/g, '')}.${params.format === 'pdf' ? 'pdf' : 'xlsx'}`; const filename = `report_${params.start_date.replace(/-/g, '')}_to_${params.end_date.replace(/-/g, '')}.${params.format === 'pdf' ? 'pdf' : 'xlsx'}`;
link.download = filename; link.download = filename;
// Trigger the download // Trigger the download
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
// Clean up // Clean up
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);