feat: 新增核心 API 客户端、认证服务(含令牌刷新和 GitHub OAuth)、图片管理功能及相关 UI 组件和测试。
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user