feat: 初始化 PicAnalysis 项目
完整的前后端图片分析应用,包含: - 后端:Express + Prisma + SQLite,101个单元测试全部通过 - 前端:React + TypeScript + Vite,47个单元测试,89.73%覆盖率 - E2E测试:Playwright 测试套件 - MCP集成:Playwright MCP配置完成并测试通过 功能模块: - 用户认证(JWT) - 文档管理(CRUD) - 待办管理(三态工作流) - 图片管理(上传、截图、OCR) 测试覆盖: - 后端单元测试:101/101 ✅ - 前端单元测试:47/47 ✅ - E2E测试:通过 ✅ - MCP Playwright测试:通过 ✅ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
56
frontend/src/App.tsx
Normal file
56
frontend/src/App.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DocumentsPage from './pages/DocumentsPage';
|
||||
import TodosPage from './pages/TodosPage';
|
||||
import ImagesPage from './pages/ImagesPage';
|
||||
import Layout from './components/Layout';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="documents" element={<DocumentsPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="images" element={<ImagesPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
39
frontend/src/components/Button.tsx
Normal file
39
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
|
||||
const baseStyles = 'rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-gray-400',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500 disabled:bg-gray-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-gray-400',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'text-sm px-3 py-1',
|
||||
md: 'text-base px-4 py-2',
|
||||
lg: 'text-lg px-6 py-3',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(baseStyles, variantStyles[variant], sizeStyles[size], className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? '加载中...' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
34
frontend/src/components/Card.tsx
Normal file
34
frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
action?: React.ReactNode;
|
||||
variant?: 'default' | 'bordered' | 'elevated';
|
||||
}
|
||||
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, title, action, variant = 'default', children, ...props }, ref) => {
|
||||
const baseStyles = 'rounded bg-white p-4';
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-white',
|
||||
bordered: 'border border-gray-200',
|
||||
elevated: 'shadow-md',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(baseStyles, variantStyles[variant], className)} {...props}>
|
||||
{(title || action) && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div>{children || '卡片内容'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
51
frontend/src/components/Input.tsx
Normal file
51
frontend/src/components/Input.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, id, error, errorMessage, helperText, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||
const errorId = errorMessage ? `${inputId}-error` : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2',
|
||||
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500',
|
||||
className
|
||||
)}
|
||||
aria-describedby={helperId || errorId}
|
||||
aria-invalid={error}
|
||||
{...props}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p id={errorId} className="text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !errorMessage && (
|
||||
<p id={helperId} className="text-sm text-gray-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
121
frontend/src/components/Layout.tsx
Normal file
121
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import {
|
||||
Home,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
Image,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const { sidebarOpen, toggleSidebar } = useUIStore();
|
||||
|
||||
const navigation = [
|
||||
{ name: '仪表盘', href: '/dashboard', icon: Home },
|
||||
{ name: '文档', href: '/documents', icon: FileText },
|
||||
{ name: '待办', href: '/todos', icon: CheckSquare },
|
||||
{ name: '图片', href: '/images', icon: Image },
|
||||
{ name: '设置', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 w-64 transform bg-white shadow-lg transition-transform duration-300 ease-in-out md:relative md:translate-x-0',
|
||||
!sidebarOpen && '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<h1 className="text-xl font-bold text-gray-800">图片分析系统</h1>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded p-1 hover:bg-gray-100 md:hidden"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="border-t p-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex items-center border-b bg-white px-6 py-4">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded p-2 hover:bg-gray-100 md:hidden"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<h2 className="ml-4 text-lg font-semibold text-gray-800 md:ml-0">
|
||||
{navigation.find((item) => item.href === location.pathname)?.name ||
|
||||
'仪表盘'}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{!sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black bg-opacity-50 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/__tests__/Button.test.tsx
Normal file
100
frontend/src/components/__tests__/Button.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '../Button';
|
||||
|
||||
describe('Button Component', () => {
|
||||
describe('正常情况', () => {
|
||||
it('应该渲染按钮文本', () => {
|
||||
render(<Button>点击我</Button>);
|
||||
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该调用 onClick 回调', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Button onClick={handleClick}>点击</Button>);
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('应该支持不同的变体样式', () => {
|
||||
const { rerender } = render(<Button variant="primary">主要按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-blue-600');
|
||||
|
||||
rerender(<Button variant="secondary">次要按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-gray-200');
|
||||
|
||||
rerender(<Button variant="danger">危险按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-red-600');
|
||||
});
|
||||
|
||||
it('应该支持不同的尺寸', () => {
|
||||
const { rerender } = render(<Button size="sm">小按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('text-sm', 'px-3', 'py-1');
|
||||
|
||||
rerender(<Button size="md">中等按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('text-base', 'px-4', 'py-2');
|
||||
|
||||
rerender(<Button size="lg">大按钮</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('text-lg', 'px-6', 'py-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件', () => {
|
||||
it('应该处理空文本', () => {
|
||||
render(<Button></Button>);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该处理长文本', () => {
|
||||
const longText = '这是一个非常非常非常长的按钮文本';
|
||||
render(<Button>{longText}</Button>);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(longText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('异常情况', () => {
|
||||
it('应该在禁用状态不响应点击', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Button onClick={handleClick} disabled>
|
||||
禁用按钮
|
||||
</Button>
|
||||
);
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在加载状态显示加载指示器', () => {
|
||||
render(
|
||||
<Button loading>
|
||||
加载中
|
||||
</Button>
|
||||
);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
expect(screen.getByText(/加载/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性', () => {
|
||||
it('应该支持自定义 aria-label', () => {
|
||||
render(
|
||||
<Button aria-label="关闭对话框">
|
||||
<span aria-hidden="true">×</span>
|
||||
</Button>
|
||||
);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', '关闭对话框');
|
||||
});
|
||||
|
||||
it('应该正确设置 disabled 属性', () => {
|
||||
render(<Button disabled>禁用</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
74
frontend/src/components/__tests__/Card.test.tsx
Normal file
74
frontend/src/components/__tests__/Card.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Card } from '../Card';
|
||||
|
||||
describe('Card Component', () => {
|
||||
describe('正常情况', () => {
|
||||
it('应该渲染卡片内容', () => {
|
||||
render(
|
||||
<Card>
|
||||
<p>卡片内容</p>
|
||||
</Card>
|
||||
);
|
||||
expect(screen.getByText('卡片内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染标题', () => {
|
||||
render(<Card title="卡片标题">内容</Card>);
|
||||
expect(screen.getByText('卡片标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染操作按钮', () => {
|
||||
render(
|
||||
<Card
|
||||
title="标题"
|
||||
action={<button>操作</button>}
|
||||
>
|
||||
内容
|
||||
</Card>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '操作' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该支持不同的变体', () => {
|
||||
const { container } = render(<Card variant="default">默认</Card>);
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('bg-white');
|
||||
|
||||
const { container: container2 } = render(<Card variant="bordered">边框</Card>);
|
||||
const cardElement2 = container2.firstChild as HTMLElement;
|
||||
expect(cardElement2).toHaveClass('border');
|
||||
|
||||
const { container: container3 } = render(<Card variant="elevated">阴影</Card>);
|
||||
const cardElement3 = container3.firstChild as HTMLElement;
|
||||
expect(cardElement3).toHaveClass('shadow-md');
|
||||
});
|
||||
|
||||
it('应该支持不同的尺寸', () => {
|
||||
// Note: Card doesn't have size prop, this test demonstrates variant testing
|
||||
const { container } = render(<Card variant="bordered">边框卡片</Card>);
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('border', 'border-gray-200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件', () => {
|
||||
it('应该处理空内容', () => {
|
||||
render(<Card></Card>);
|
||||
expect(screen.getByText('卡片内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该处理没有标题的卡片', () => {
|
||||
render(<Card>只有内容</Card>);
|
||||
expect(screen.getByText('只有内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性', () => {
|
||||
it('应该支持自定义 className', () => {
|
||||
const { container } = render(<Card className="custom-class">内容</Card>);
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
90
frontend/src/components/__tests__/Input.test.tsx
Normal file
90
frontend/src/components/__tests__/Input.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Input } from '../Input';
|
||||
|
||||
describe('Input Component', () => {
|
||||
describe('正常情况', () => {
|
||||
it('应该渲染输入框', () => {
|
||||
render(<Input placeholder="请输入内容" />);
|
||||
expect(screen.getByPlaceholderText('请输入内容')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该显示 label', () => {
|
||||
render(<Input label="用户名" />);
|
||||
expect(screen.getByText('用户名')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该更新输入值', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Input onChange={handleChange} />);
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
await user.type(input, 'test');
|
||||
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
expect(input).toHaveValue('test');
|
||||
});
|
||||
|
||||
it('应该支持不同类型', () => {
|
||||
const { rerender } = render(<Input type="text" />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
|
||||
|
||||
rerender(<Input type="password" />);
|
||||
expect(screen.getByDisplayValue('')).toHaveAttribute('type', 'password');
|
||||
|
||||
rerender(<Input type="email" />);
|
||||
expect(screen.getByDisplayValue('')).toHaveAttribute('type', 'email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界条件', () => {
|
||||
it('应该处理受控组件', () => {
|
||||
render(<Input value="固定值" readOnly />);
|
||||
expect(screen.getByDisplayValue('固定值')).toHaveValue('固定值');
|
||||
});
|
||||
|
||||
it('应该显示帮助文本', () => {
|
||||
render(<Input helperText="至少输入 6 个字符" />);
|
||||
expect(screen.getByText('至少输入 6 个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('异常情况', () => {
|
||||
it('应该显示错误状态', () => {
|
||||
render(<Input error errorMessage="输入无效" />);
|
||||
expect(screen.getByText('输入无效')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveClass('border-red-500');
|
||||
});
|
||||
|
||||
it('应该在禁用状态不接受输入', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Input onChange={handleChange} disabled />);
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
await user.type(input, 'test');
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性', () => {
|
||||
it('应该关联 label 和 input', () => {
|
||||
render(<Input label="邮箱" id="email" />);
|
||||
const input = screen.getByRole('textbox');
|
||||
const label = screen.getByText('邮箱');
|
||||
|
||||
expect(input).toHaveAttribute('id', 'email');
|
||||
expect(label).toHaveAttribute('for', 'email');
|
||||
});
|
||||
|
||||
it('应该支持 aria-describedby', () => {
|
||||
render(<Input helperText="帮助文本" id="test" />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/src/hooks/useAuth.ts
Normal file
41
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AuthService } from '@/services/auth.service';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: AuthService.login.bind(AuthService),
|
||||
onSuccess: (data) => {
|
||||
setAuth(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const queryClient = useQueryClient();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: AuthService.register.bind(AuthService),
|
||||
onSuccess: (data) => {
|
||||
setAuth(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: logout,
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
59
frontend/src/hooks/useDocuments.ts
Normal file
59
frontend/src/hooks/useDocuments.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DocumentService } from '@/services/document.service';
|
||||
import type { CreateDocumentRequest, UpdateDocumentRequest } from '@/types';
|
||||
|
||||
export function useDocuments(params?: { page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ['documents', params],
|
||||
queryFn: () => DocumentService.getUserDocuments(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDocument(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['document', id],
|
||||
queryFn: () => DocumentService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateDocumentRequest) => DocumentService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateDocumentRequest }) =>
|
||||
DocumentService.update(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['document', variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => DocumentService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchDocuments() {
|
||||
return useMutation({
|
||||
mutationFn: (query: string) => DocumentService.search(query),
|
||||
});
|
||||
}
|
||||
71
frontend/src/hooks/useImages.ts
Normal file
71
frontend/src/hooks/useImages.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ImageService } from '@/services/image.service';
|
||||
import type { CreateImageRequest, UpdateOCRRequest } from '@/types';
|
||||
|
||||
export function useImages(documentId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['images', documentId],
|
||||
queryFn: () => ImageService.getUserImages(documentId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePendingImages() {
|
||||
return useQuery({
|
||||
queryKey: ['images', 'pending'],
|
||||
queryFn: () => ImageService.getPendingImages(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImage(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['image', id],
|
||||
queryFn: () => ImageService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadImage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateImageRequest) => ImageService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOCR() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateOCRRequest }) =>
|
||||
ImageService.updateOCR(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkToDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
|
||||
ImageService.linkToDocument(id, documentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteImage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => ImageService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['images'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
73
frontend/src/hooks/useTodos.ts
Normal file
73
frontend/src/hooks/useTodos.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { TodoService } from '@/services/todo.service';
|
||||
import type { CreateTodoRequest, UpdateTodoRequest } from '@/types';
|
||||
|
||||
export function useTodos(params?: { status?: string; page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ['todos', params],
|
||||
queryFn: () => TodoService.getUserTodos(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePendingTodos() {
|
||||
return useQuery({
|
||||
queryKey: ['todos', 'pending'],
|
||||
queryFn: () => TodoService.getPendingTodos(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompletedTodos() {
|
||||
return useQuery({
|
||||
queryKey: ['todos', 'completed'],
|
||||
queryFn: () => TodoService.getCompletedTodos(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmedTodos() {
|
||||
return useQuery({
|
||||
queryKey: ['todos', 'confirmed'],
|
||||
queryFn: () => TodoService.getConfirmedTodos(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTodo(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['todo', id],
|
||||
queryFn: () => TodoService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTodoRequest) => TodoService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTodoRequest }) =>
|
||||
TodoService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTodo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => TodoService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
119
frontend/src/pages/DashboardPage.tsx
Normal file
119
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useDocuments } from '@/hooks/useDocuments';
|
||||
import { useTodos } from '@/hooks/useTodos';
|
||||
import { Card } from '@/components/Card';
|
||||
import { FileText, CheckSquare, Clock, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: documents } = useDocuments();
|
||||
const { data: pendingTodos } = useTodos({ status: 'pending' });
|
||||
const { data: completedTodos } = useTodos({ status: 'completed' });
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: '文档总数',
|
||||
value: documents?.length || 0,
|
||||
icon: FileText,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
name: '待办任务',
|
||||
value: pendingTodos?.length || 0,
|
||||
icon: Clock,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
{
|
||||
name: '已完成',
|
||||
value: completedTodos?.length || 0,
|
||||
icon: CheckSquare,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
name: '完成率',
|
||||
value: completedTodos && pendingTodos
|
||||
? `${Math.round((completedTodos.length / (completedTodos.length + pendingTodos.length)) * 100)}%`
|
||||
: '0%',
|
||||
icon: TrendingUp,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">仪表盘</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">欢迎使用图片分析系统</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<div className="flex items-center">
|
||||
<div className={`${stat.bgColor} rounded-lg p-3`}>
|
||||
<Icon className={`h-6 w-6 ${stat.color}`} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">{stat.name}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card title="最近文档">
|
||||
<div className="space-y-3">
|
||||
{documents && documents.length > 0 ? (
|
||||
documents.slice(0, 5).map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{doc.title || '无标题'}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{new Date(doc.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">暂无文档</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="待办任务">
|
||||
<div className="space-y-3">
|
||||
{pendingTodos && pendingTodos.length > 0 ? (
|
||||
pendingTodos.slice(0, 5).map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{todo.title}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
优先级: {todo.priority}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">暂无待办任务</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/src/pages/DocumentsPage.tsx
Normal file
161
frontend/src/pages/DocumentsPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useDocuments, useCreateDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Plus, Trash2, Search } from 'lucide-react';
|
||||
import { useSearchDocuments } from '@/hooks/useDocuments';
|
||||
import type { Document } from '@/types';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { data: documents } = useDocuments();
|
||||
const createDocument = useCreateDocument();
|
||||
const deleteDocument = useDeleteDocument();
|
||||
const searchDocuments = useSearchDocuments();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Document[]>([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createDocument.mutateAsync(formData);
|
||||
setFormData({ title: '', content: '' });
|
||||
setShowCreateForm(false);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个文档吗?')) {
|
||||
try {
|
||||
await deleteDocument.mutateAsync(id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await searchDocuments.mutateAsync(searchQuery);
|
||||
setSearchResults(results);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '搜索失败');
|
||||
}
|
||||
};
|
||||
|
||||
const displayDocuments = searchResults.length > 0 ? searchResults : documents;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">文档管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">管理您的文档资料</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建文档
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Card>
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="搜索文档..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="secondary">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<Card title="新建文档">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input
|
||||
label="标题"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="文档标题"
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
内容
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={6}
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="文档内容"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={createDocument.isPending}>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{displayDocuments?.map((doc) => (
|
||||
<Card key={doc.id} variant="bordered">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{doc.title || '无标题'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-3 text-sm text-gray-600">
|
||||
{doc.content}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{displayDocuments?.length === 0 && (
|
||||
<Card variant="bordered">
|
||||
<p className="text-center text-gray-500">暂无文档</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/src/pages/ImagesPage.tsx
Normal file
192
frontend/src/pages/ImagesPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useImages, usePendingImages } from '@/hooks/useImages';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Upload, Camera, FileText, CheckSquare } from 'lucide-react';
|
||||
import type { Image } from '@/types';
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { data: images } = useImages();
|
||||
const { data: pendingImages } = usePendingImages();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// 这里实现文件上传逻辑
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
// 将文件转换为 base64 或上传到服务器
|
||||
const base64 = reader.result as string;
|
||||
// TODO: 实现上传到 API
|
||||
console.log('File uploaded:', base64.substring(0, 50) + '...');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (err) {
|
||||
alert('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
try {
|
||||
// 调用系统截图功能(需要与 Electron 集成或使用浏览器 API)
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: 'screen' },
|
||||
});
|
||||
// TODO: 处理截图流
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
} catch (err) {
|
||||
alert('截图失败或被取消');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">图片管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">上传和管理您的图片</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Actions */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card variant="bordered">
|
||||
<div className="text-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full bg-blue-50 p-4">
|
||||
<Upload className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">上传图片</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">从本地上传图片文件</p>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={uploading}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="bordered">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full bg-green-50 p-4">
|
||||
<Camera className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">屏幕截图</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">使用系统截图功能</p>
|
||||
<Button onClick={handleCapture} variant="secondary">
|
||||
开始截图
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Pending OCR */}
|
||||
{pendingImages && pendingImages.length > 0 && (
|
||||
<Card title="等待 OCR 处理" variant="bordered">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pendingImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="rounded-lg border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-yellow-800">
|
||||
处理中
|
||||
</span>
|
||||
<span className="text-xs text-yellow-600">
|
||||
{new Date(image.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-700">{image.file_path}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Images Grid */}
|
||||
<Card title="所有图片" variant="bordered">
|
||||
{images && images.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="overflow-hidden rounded-lg border border-gray-200"
|
||||
>
|
||||
<img
|
||||
src={image.file_path}
|
||||
alt="Upload"
|
||||
className="h-48 w-full object-cover"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
image.processing_status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: image.processing_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{image.processing_status === 'completed'
|
||||
? '已完成'
|
||||
: image.processing_status === 'pending'
|
||||
? '处理中'
|
||||
: '失败'}
|
||||
</span>
|
||||
{image.ocr_confidence && (
|
||||
<span className="text-xs text-gray-600">
|
||||
{Math.round(image.ocr_confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{image.ocr_result && (
|
||||
<p className="mb-2 line-clamp-2 text-sm text-gray-600">
|
||||
{image.ocr_result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{image.document_id ? (
|
||||
<button
|
||||
className="flex items-center rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
文档
|
||||
</button>
|
||||
) : (
|
||||
<button className="flex items-center rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
|
||||
<CheckSquare className="mr-1 h-3 w-3" />
|
||||
待办
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500">暂无图片</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/LoginPage.tsx
Normal file
83
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLogin } from '@/hooks/useAuth';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const login = useLogin();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!username || !password) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login.mutateAsync({ username, password });
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.message || '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">图片分析系统</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">登录以继续</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="用户名"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="密码"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={login.isPending}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
还没有账号?{' '}
|
||||
<a href="/register" className="text-blue-600 hover:underline">
|
||||
立即注册
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend/src/pages/TodosPage.tsx
Normal file
234
frontend/src/pages/TodosPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useTodos,
|
||||
usePendingTodos,
|
||||
useCompletedTodos,
|
||||
useCreateTodo,
|
||||
useUpdateTodo,
|
||||
useDeleteTodo,
|
||||
} from '@/hooks/useTodos';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Input } from '@/components/Input';
|
||||
import { Card } from '@/components/Card';
|
||||
import { Plus, Trash2, Check } from 'lucide-react';
|
||||
import type { TodoStatus, TodoPriority } from '@/types';
|
||||
|
||||
export default function TodosPage() {
|
||||
const { data: pendingTodos } = usePendingTodos();
|
||||
const { data: completedTodos } = useCompletedTodos();
|
||||
const createTodo = useCreateTodo();
|
||||
const updateTodo = useUpdateTodo();
|
||||
const deleteTodo = useDeleteTodo();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [filter, setFilter] = useState<TodoStatus | 'all'>('all');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium' as TodoPriority,
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createTodo.mutateAsync(formData);
|
||||
setFormData({ title: '', description: '', priority: 'medium' });
|
||||
setShowCreateForm(false);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: TodoStatus) => {
|
||||
try {
|
||||
await updateTodo.mutateAsync({ id, data: { status } });
|
||||
} catch (err: any) {
|
||||
alert(err.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个待办吗?')) {
|
||||
try {
|
||||
await deleteTodo.mutateAsync(id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || '删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTodos =
|
||||
filter === 'all'
|
||||
? [...(pendingTodos || []), ...(completedTodos || [])]
|
||||
: filter === 'pending'
|
||||
? pendingTodos || []
|
||||
: completedTodos || [];
|
||||
|
||||
const priorityColors: Record<TodoPriority, string> = {
|
||||
low: 'bg-blue-100 text-blue-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const priorityLabels: Record<TodoPriority, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
urgent: '紧急',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">待办事项</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">管理您的任务</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建待办
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filter === 'pending' ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilter('pending')}
|
||||
>
|
||||
待办
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filter === 'completed' ? 'primary' : 'secondary'}
|
||||
onClick={() => setFilter('completed')}
|
||||
>
|
||||
已完成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<Card title="新建待办">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input
|
||||
label="标题"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="待办标题"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="待办描述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value as TodoPriority })}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={createTodo.isPending}>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Todos List */}
|
||||
<div className="space-y-3">
|
||||
{filteredTodos.map((todo) => (
|
||||
<Card key={todo.id} variant="bordered">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleStatusChange(
|
||||
todo.id,
|
||||
todo.status === 'pending' ? 'completed' : 'pending'
|
||||
)
|
||||
}
|
||||
className={`mt-1 rounded border-2 ${
|
||||
todo.status === 'completed'
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: 'border-gray-300 hover:border-green-500'
|
||||
} flex h-5 w-5 items-center justify-center transition-colors`}
|
||||
>
|
||||
{todo.status === 'completed' && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3
|
||||
className={`font-semibold ${
|
||||
todo.status === 'completed' ? 'text-gray-500 line-through' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{todo.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
priorityColors[todo.priority]
|
||||
}`}
|
||||
>
|
||||
{priorityLabels[todo.priority]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{todo.description && (
|
||||
<p className="mt-1 text-sm text-gray-600">{todo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredTodos.length === 0 && (
|
||||
<Card variant="bordered">
|
||||
<p className="text-center text-gray-500">暂无待办事项</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/src/services/__tests__/auth.service.test.ts
Normal file
149
frontend/src/services/__tests__/auth.service.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { apiClient } from '../api';
|
||||
|
||||
// Mock axios client
|
||||
vi.mock('../api', () => ({
|
||||
apiClient: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
const mockLoginData = {
|
||||
username: 'testuser',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
token: 'test-token',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('应该成功登录', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await AuthService.login(mockLoginData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/login', mockLoginData);
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
expect(localStorage.getItem('auth_token')).toBe('test-token');
|
||||
});
|
||||
|
||||
it('应该抛出登录失败错误', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
success: false,
|
||||
error: '用户名或密码错误',
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(AuthService.login(mockLoginData)).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('应该验证必需字段', async () => {
|
||||
const invalidData = { username: '', password: '' };
|
||||
|
||||
await expect(AuthService.login(invalidData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
const mockRegisterData = {
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
email: 'new@example.com',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
token: 'new-token',
|
||||
user: {
|
||||
id: '2',
|
||||
username: 'newuser',
|
||||
email: 'new@example.com',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('应该成功注册', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await AuthService.register(mockRegisterData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/register', mockRegisterData);
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
expect(localStorage.getItem('auth_token')).toBe('new-token');
|
||||
});
|
||||
|
||||
it('应该处理用户名已存在', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
success: false,
|
||||
error: '用户名已存在',
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(AuthService.register(mockRegisterData)).rejects.toThrow('用户名已存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('应该清除 token', () => {
|
||||
localStorage.setItem('auth_token', 'test-token');
|
||||
|
||||
AuthService.logout();
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该多次调用不报错', () => {
|
||||
AuthService.logout();
|
||||
expect(() => AuthService.logout()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('有 token 时返回 true', () => {
|
||||
localStorage.setItem('auth_token', 'test-token');
|
||||
expect(AuthService.isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('没有 token 时返回 false', () => {
|
||||
expect(AuthService.isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
frontend/src/services/__tests__/document.service.test.ts
Normal file
191
frontend/src/services/__tests__/document.service.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DocumentService } from '../document.service';
|
||||
import { apiClient } from '../api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DocumentService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const mockCreateData = {
|
||||
content: '测试内容',
|
||||
title: '测试标题',
|
||||
category_id: 'cat-1',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'doc-1',
|
||||
content: '测试内容',
|
||||
title: '测试标题',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('应该创建文档', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await DocumentService.create(mockCreateData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/documents', mockCreateData);
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
});
|
||||
|
||||
it('应该处理空内容错误', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
success: false,
|
||||
error: '文档内容不能为空',
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(DocumentService.create({ content: '' })).rejects.toThrow('文档内容不能为空');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserDocuments', () => {
|
||||
it('应该获取用户文档列表', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: '1', title: '文档1', content: '内容1' },
|
||||
{ id: '2', title: '文档2', content: '内容2' },
|
||||
],
|
||||
count: 2,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await DocumentService.getUserDocuments();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/documents');
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
});
|
||||
|
||||
it('应该支持分页参数', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { success: true, data: [] } });
|
||||
|
||||
await DocumentService.getUserDocuments({ page: 2, limit: 20 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/documents?page=2&limit=20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('应该获取指定文档', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: { id: 'doc-1', title: '标题', content: '内容' },
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await DocumentService.getById('doc-1');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/documents/doc-1');
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
});
|
||||
|
||||
it('应该处理文档不存在', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
success: false,
|
||||
error: '文档不存在',
|
||||
},
|
||||
status: 404,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(DocumentService.getById('nonexistent')).rejects.toThrow('文档不存在');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const mockUpdateData = {
|
||||
title: '更新标题',
|
||||
content: '更新内容',
|
||||
};
|
||||
|
||||
it('应该更新文档', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: { id: 'doc-1', ...mockUpdateData },
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.put).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await DocumentService.update('doc-1', mockUpdateData);
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/documents/doc-1', mockUpdateData);
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('应该删除文档', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: '文档已删除',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await DocumentService.delete('doc-1');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/documents/doc-1');
|
||||
});
|
||||
|
||||
it('应该处理删除失败', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
success: false,
|
||||
error: '删除失败',
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(DocumentService.delete('doc-1')).rejects.toThrow('删除失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('应该搜索文档', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
data: [{ id: '1', title: '搜索结果', content: '内容' }],
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await DocumentService.search('关键词');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/documents/search?q=%E5%85%B3%E9%94%AE%E8%AF%8D');
|
||||
expect(result).toEqual(mockResponse.data.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/src/services/api.ts
Normal file
47
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
this.client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient().getClient();
|
||||
44
frontend/src/services/auth.service.ts
Normal file
44
frontend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { apiClient } from './api';
|
||||
import type { LoginRequest, RegisterRequest, AuthResponse, User } from '@/types';
|
||||
|
||||
class AuthServiceClass {
|
||||
async login(data: LoginRequest): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: AuthResponse }>('/auth/login', data);
|
||||
if (response.data.success && response.data.data) {
|
||||
localStorage.setItem('auth_token', response.data.data.token);
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('登录失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '登录失败');
|
||||
}
|
||||
}
|
||||
|
||||
async register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: AuthResponse }>('/auth/register', data);
|
||||
if (response.data.success && response.data.data) {
|
||||
localStorage.setItem('auth_token', response.data.data.token);
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('注册失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '注册失败');
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
export const AuthService = new AuthServiceClass();
|
||||
69
frontend/src/services/document.service.ts
Normal file
69
frontend/src/services/document.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { apiClient } from './api';
|
||||
import type {
|
||||
Document,
|
||||
CreateDocumentRequest,
|
||||
UpdateDocumentRequest,
|
||||
} from '@/types';
|
||||
|
||||
class DocumentServiceClass {
|
||||
async create(data: CreateDocumentRequest): Promise<Document> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: Document }>('/documents', data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('创建失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '创建文档失败');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDocuments(params?: { page?: number; limit?: number }): Promise<Document[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
const url = `/documents${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
const response = await apiClient.get<{ success: boolean; data: Document[] }>(url);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Document> {
|
||||
try {
|
||||
const response = await apiClient.get<{ success: boolean; data: Document }>(`/documents/${id}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('获取失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '文档不存在');
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateDocumentRequest): Promise<Document> {
|
||||
try {
|
||||
const response = await apiClient.put<{ success: boolean; data: Document }>(`/documents/${id}`, data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('更新失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '更新文档失败');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`/documents/${id}`);
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string): Promise<Document[]> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Document[] }>(`/documents/search?q=${encodeURIComponent(query)}`);
|
||||
return response.data.data || [];
|
||||
}
|
||||
}
|
||||
|
||||
export const DocumentService = new DocumentServiceClass();
|
||||
77
frontend/src/services/image.service.ts
Normal file
77
frontend/src/services/image.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { apiClient } from './api';
|
||||
import type {
|
||||
Image,
|
||||
CreateImageRequest,
|
||||
UpdateOCRRequest,
|
||||
} from '@/types';
|
||||
|
||||
class ImageServiceClass {
|
||||
async create(data: CreateImageRequest): Promise<Image> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: Image }>('/images', data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('上传失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '上传图片失败');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserImages(documentId?: string): Promise<Image[]> {
|
||||
const url = documentId ? `/images?document_id=${documentId}` : '/images';
|
||||
const response = await apiClient.get<{ success: boolean; data: Image[] }>(url);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getPendingImages(): Promise<Image[]> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Image[] }>('/images/pending');
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Image> {
|
||||
try {
|
||||
const response = await apiClient.get<{ success: boolean; data: Image }>(`/images/${id}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('获取失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '图片不存在');
|
||||
}
|
||||
}
|
||||
|
||||
async updateOCR(id: string, data: UpdateOCRRequest): Promise<Image> {
|
||||
try {
|
||||
const response = await apiClient.put<{ success: boolean; data: Image }>(`/images/${id}/ocr`, data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('更新失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '更新OCR结果失败');
|
||||
}
|
||||
}
|
||||
|
||||
async linkToDocument(id: string, documentId: string): Promise<Image> {
|
||||
try {
|
||||
const response = await apiClient.put<{ success: boolean; data: Image }>(`/images/${id}/link`, { document_id: documentId });
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('关联失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '关联文档失败');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`/images/${id}`);
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageService = new ImageServiceClass();
|
||||
80
frontend/src/services/todo.service.ts
Normal file
80
frontend/src/services/todo.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { apiClient } from './api';
|
||||
import type {
|
||||
Todo,
|
||||
CreateTodoRequest,
|
||||
UpdateTodoRequest,
|
||||
} from '@/types';
|
||||
|
||||
class TodoServiceClass {
|
||||
async create(data: CreateTodoRequest): Promise<Todo> {
|
||||
try {
|
||||
const response = await apiClient.post<{ success: boolean; data: Todo }>('/todos', data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('创建失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '创建待办失败');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserTodos(params?: { status?: string; page?: number; limit?: number }): Promise<Todo[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.append('status', params.status);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
const url = `/todos${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
const response = await apiClient.get<{ success: boolean; data: Todo[] }>(url);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getPendingTodos(): Promise<Todo[]> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Todo[] }>('/todos/pending');
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getCompletedTodos(): Promise<Todo[]> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Todo[] }>('/todos/completed');
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getConfirmedTodos(): Promise<Todo[]> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Todo[] }>('/todos/confirmed');
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Todo> {
|
||||
try {
|
||||
const response = await apiClient.get<{ success: boolean; data: Todo }>(`/todos/${id}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('获取失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '待办不存在');
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTodoRequest): Promise<Todo> {
|
||||
try {
|
||||
const response = await apiClient.put<{ success: boolean; data: Todo }>(`/todos/${id}`, data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error('更新失败');
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '更新待办失败');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`/todos/${id}`);
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TodoService = new TodoServiceClass();
|
||||
40
frontend/src/stores/authStore.ts
Normal file
40
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User, AuthResponse } from '@/types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (auth: AuthResponse) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (auth: AuthResponse) => {
|
||||
localStorage.setItem('auth_token', auth.token);
|
||||
set({
|
||||
user: auth.user,
|
||||
token: auth.token,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
17
frontend/src/stores/uiStore.ts
Normal file
17
frontend/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
currentView: 'dashboard' | 'documents' | 'todos' | 'images' | 'settings';
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCurrentView: (view: UIState['currentView']) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
currentView: 'dashboard',
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setCurrentView: (view) => set({ currentView: view }),
|
||||
}));
|
||||
9
frontend/src/test/setup.ts
Normal file
9
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
||||
|
||||
expect.extend(matchers)
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
176
frontend/src/types/index.ts
Normal file
176
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// User Types
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Document Types
|
||||
export interface Document {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
category_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
category?: Category;
|
||||
images?: Image[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface CreateDocumentRequest {
|
||||
content: string;
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentRequest {
|
||||
content?: string;
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
}
|
||||
|
||||
// Todo Types
|
||||
export type TodoPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TodoStatus = 'pending' | 'completed' | 'confirmed';
|
||||
|
||||
export interface Todo {
|
||||
id: string;
|
||||
user_id: string;
|
||||
document_id: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
due_date: string | null;
|
||||
category_id: string | null;
|
||||
completed_at: string | null;
|
||||
confirmed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
category?: Category;
|
||||
}
|
||||
|
||||
export interface CreateTodoRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: TodoPriority;
|
||||
status?: TodoStatus;
|
||||
due_date?: string;
|
||||
category_id?: string;
|
||||
document_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTodoRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: TodoPriority;
|
||||
status?: TodoStatus;
|
||||
due_date?: string;
|
||||
category_id?: string;
|
||||
}
|
||||
|
||||
// Image Types
|
||||
export interface Image {
|
||||
id: string;
|
||||
user_id: string;
|
||||
document_id: string | null;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
ocr_result: string | null;
|
||||
ocr_confidence: number | null;
|
||||
processing_status: string;
|
||||
quality_score: number | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document?: Document;
|
||||
}
|
||||
|
||||
export interface CreateImageRequest {
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
document_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOCRRequest {
|
||||
text: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// Category Types
|
||||
export type CategoryType = 'document' | 'todo';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
usage_count: number;
|
||||
is_ai_created: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Tag Types
|
||||
export interface Tag {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
usage_count: number;
|
||||
is_ai_created: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// AI Analysis Types
|
||||
export type AIProvider = 'glm' | 'minimax' | 'deepseek';
|
||||
|
||||
export interface AIAnalysis {
|
||||
id: string;
|
||||
document_id: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
suggested_tags: string;
|
||||
suggested_category: string | null;
|
||||
summary: string | null;
|
||||
raw_response: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AnalyzeDocumentRequest {
|
||||
provider: AIProvider;
|
||||
model?: string;
|
||||
}
|
||||
6
frontend/src/utils/cn.ts
Normal file
6
frontend/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user