test build
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
jze9
2026-05-18 01:14:40 +05:00
commit 2a14350ee3
46 changed files with 3620 additions and 0 deletions

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Антиплагиат</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

52
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,52 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
# Cache static assets (JS/CSS с хешами в имени — кешируем надолго)
location ~* \.(js|css|woff2?|ttf|svg|png|jpg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# React SPA — все неизвестные пути → index.html (не кешируем)
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
# Proxy REST API → FastAPI
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Proxy WebSocket → FastAPI (/ws/ и /api/admin/ws/)
location ~ ^/(ws/|api/admin/ws/) {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# MinIO console
location /minio/ {
proxy_pass http://minio:9001/;
proxy_set_header Host $host;
}
}

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "antiplagiator-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"zustand": "^4.4.0",
"axios": "^1.6.0",
"react-query": "^3.39.3",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0",
"typescript": "^5.3.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

98
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import AuthPage from './pages/AuthPage'
import Home from './pages/Home'
import Report from './pages/Report'
import HistoryPage from './pages/HistoryPage'
import AdminPage from './pages/AdminPage'
import { getMe } from './api/client'
type Screen = 'home' | 'report' | 'history' | 'admin'
export default function App() {
const [authed, setAuthed] = useState<boolean | null>(null) // null = checking
const [screen, setScreen] = useState<Screen>('home')
const [taskId, setTaskId] = useState<string | null>(null)
// Check stored token on mount
useEffect(() => {
const token = localStorage.getItem('token')
if (!token) { setAuthed(false); return }
getMe()
.then(() => setAuthed(true))
.catch(() => { localStorage.removeItem('token'); setAuthed(false) })
}, [])
const handleAuth = (token: string) => {
if (token) localStorage.setItem('token', token)
setAuthed(true)
}
const handleLogout = () => {
localStorage.removeItem('token')
setAuthed(false)
setScreen('home')
setTaskId(null)
}
const openReport = (id: string) => {
setTaskId(id)
setScreen('report')
}
// Loading state while checking token
if (authed === null) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center text-gray-400">
Загрузка
</div>
)
}
if (!authed) {
return <AuthPage onAuth={handleAuth} />
}
return (
<div className="min-h-screen flex flex-col">
{/* Top nav — only on home/history */}
{screen !== 'report' && (
<nav className="bg-white border-b px-6 py-3 flex items-center justify-between">
<div className="flex gap-4 text-sm font-medium">
{([['home', 'Проверка'], ['history', 'История'], ['admin', 'База']] as const).map(([s, label]) => (
<button
key={s}
onClick={() => setScreen(s)}
className={screen === s ? 'text-blue-600' : 'text-gray-500 hover:text-gray-800'}
>
{label}
</button>
))}
</div>
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-gray-700">
Выйти
</button>
</nav>
)}
{screen === 'home' && (
<Home onTaskCreated={openReport} />
)}
{screen === 'history' && (
<HistoryPage
onOpen={openReport}
onNewCheck={() => setScreen('home')}
/>
)}
{screen === 'admin' && <AdminPage />}
{screen === 'report' && taskId && (
<Report
taskId={taskId}
onBack={() => setScreen('home')}
/>
)}
</div>
)
}

160
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,160 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
// Attach token from localStorage on every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export interface CheckResponse {
task_id: string
status: string
message: string
}
export interface MatchResult {
method: string
similarity: number
source_title?: string
source_db?: string
url?: string
source_id?: string
}
export interface ReportResponse {
task_id: string
status: string
overall_similarity?: number
matches?: MatchResult[]
source_text?: string
created_at?: string
completed_at?: string
}
export interface ProgressEvent {
stage: 'fingerprint' | 'fuzzy' | 'semantic' | 'saving' | 'done' | 'failed'
progress: number
}
export function openProgressSocket(taskId: string, onEvent: (e: ProgressEvent) => void): () => void {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/progress/${taskId}`)
ws.onmessage = (e) => {
try { onEvent(JSON.parse(e.data)) } catch {}
}
return () => ws.close()
}
export interface ReportSummary {
task_id: string
status: string
overall_similarity?: number
created_at?: string
}
export const uploadDocument = async (file: File): Promise<CheckResponse> => {
const form = new FormData()
form.append('file', file)
const res = await api.post<CheckResponse>('/check', form)
return res.data
}
export const getReport = async (taskId: string): Promise<ReportResponse> => {
const res = await api.get<ReportResponse>(`/report/${taskId}`)
return res.data
}
export const listReports = async (): Promise<ReportSummary[]> => {
const res = await api.get<{ tasks: ReportSummary[] }>('/reports')
return res.data.tasks
}
export interface UserInfo {
id: string
email: string
created_at: string
}
export const register = async (email: string, password: string): Promise<void> => {
await api.post('/auth/register', { email, password })
}
export const login = async (email: string, password: string): Promise<string> => {
const form = new URLSearchParams({ username: email, password })
const res = await api.post<{ access_token: string }>('/auth/login', form, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
return res.data.access_token
}
export const getMe = async (): Promise<UserInfo> => {
const res = await api.get<UserInfo>('/auth/me')
return res.data
}
// ── Admin / Index ──────────────────────────────────────────────────────────
export interface IndexSource {
key: string
label: string
}
export interface JobStatus {
status: string
source?: string
label?: string
progress: number
message: string
started_at?: string
finished_at?: string
docs_done: number
}
export const getIndexSources = async (): Promise<IndexSource[]> => {
const res = await api.get<IndexSource[]>('/admin/index/sources')
return res.data
}
export const startDownload = async (sourceKey: string): Promise<JobStatus> => {
const res = await api.post<JobStatus>(`/admin/index/download/${sourceKey}`)
return res.data
}
export const startUpdate = async (sourceKey: string): Promise<JobStatus> => {
const res = await api.post<JobStatus>(`/admin/index/update/${sourceKey}`)
return res.data
}
export const getJobStatus = async (): Promise<JobStatus> => {
const res = await api.get<JobStatus>('/admin/index/status')
return res.data
}
export function openIndexSocket(onEvent: (e: JobStatus) => void): () => void {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${window.location.host}/api/admin/ws/index-progress`)
ws.onmessage = (e) => {
try { onEvent(JSON.parse(e.data)) } catch {}
}
return () => ws.close()
}
export const downloadReportPdf = async (taskId: string): Promise<void> => {
const token = localStorage.getItem('token')
const res = await fetch(`/api/report/${taskId}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('PDF download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `report-${taskId.slice(0, 8)}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
interface Props {
onFile: (file: File) => void
disabled?: boolean
}
const ACCEPTED = { 'text/plain': ['.txt'], 'application/pdf': ['.pdf'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] }
export default function DropZone({ onFile, disabled }: Props) {
const [error, setError] = useState<string | null>(null)
const onDrop = useCallback((accepted: File[], rejected: { errors: { message: string }[] }[]) => {
setError(null)
if (rejected.length) {
setError('Поддерживаются только .txt, .pdf, .docx')
return
}
if (accepted[0]) onFile(accepted[0])
}, [onFile])
const { getRootProps, getInputProps, isDragActive, acceptedFiles } = useDropzone({
onDrop,
accept: ACCEPTED,
maxFiles: 1,
disabled,
})
const file = acceptedFiles[0]
return (
<div className="flex flex-col items-center gap-3">
<div
{...getRootProps()}
className={[
'w-full max-w-lg border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors',
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400',
disabled ? 'opacity-50 cursor-not-allowed' : '',
].join(' ')}
>
<input {...getInputProps()} />
<p className="text-gray-500 text-sm">
{isDragActive
? 'Отпустите файл здесь…'
: 'Перетащите файл или нажмите для выбора'}
</p>
<p className="text-xs text-gray-400 mt-1">.txt, .pdf, .docx</p>
{file && (
<p className="mt-3 text-sm font-medium text-blue-700">{file.name}</p>
)}
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef } from 'react'
import { MatchResult } from '../api/client'
import { HIGHLIGHT_COLOR } from './MatchCard'
interface Span {
start: number
end: number
method: string
matchIndex: number
}
interface Props {
text: string
matches: MatchResult[]
activeIndex: number | null
onMatchClick: (index: number) => void
}
function buildSegments(text: string, spans: Span[]) {
// Sort by start, then merge/flatten overlapping spans keeping the one with highest priority
const priority: Record<string, number> = { exact: 3, fuzzy: 2, semantic: 1 }
// Build a char-level annotation array (sparse)
const events: Array<{ pos: number; type: 'open' | 'close'; span: Span }> = []
for (const s of spans) {
events.push({ pos: s.start, type: 'open', span: s })
events.push({ pos: s.end, type: 'close', span: s })
}
events.sort((a, b) => a.pos - b.pos || (a.type === 'close' ? -1 : 1))
const segments: Array<{ text: string; span: Span | null }> = []
let cursor = 0
const active = new Map<Span, true>()
const dominant = () => {
let best: Span | null = null
for (const s of active.keys()) {
if (!best || (priority[s.method] ?? 0) > (priority[best.method] ?? 0)) best = s
}
return best
}
for (const ev of events) {
if (ev.pos > cursor) {
segments.push({ text: text.slice(cursor, ev.pos), span: dominant() })
cursor = ev.pos
}
if (ev.type === 'open') active.set(ev.span, true)
else active.delete(ev.span)
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), span: null })
}
return segments
}
export default function HighlightedText({ text, matches, activeIndex, onMatchClick }: Props) {
const spanRefs = useRef<Map<number, HTMLElement>>(new Map())
// Scroll to active match
useEffect(() => {
if (activeIndex === null) return
const el = spanRefs.current.get(activeIndex)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [activeIndex])
const spans: Span[] = matches
.map((m, i) => ({
start: m.fragment_start ?? 0,
end: m.fragment_end ?? 0,
method: m.method,
matchIndex: i,
}))
.filter((s) => s.end > s.start)
const segments = buildSegments(text, spans)
return (
<div className="font-mono text-sm leading-7 whitespace-pre-wrap break-words">
{segments.map((seg, i) => {
if (!seg.span) {
return <span key={i}>{seg.text}</span>
}
const mi = seg.span.matchIndex
const isActive = mi === activeIndex
const color = HIGHLIGHT_COLOR[seg.span.method] ?? 'bg-gray-200'
return (
<mark
key={i}
ref={(el) => { if (el) spanRefs.current.set(mi, el) }}
onClick={() => onMatchClick(mi)}
className={[
color,
'cursor-pointer rounded px-0.5 transition-all',
isActive ? 'ring-2 ring-blue-500 brightness-90' : 'hover:brightness-90',
].join(' ')}
title={`${matches[mi]?.source_title ?? ''}${matches[mi]?.similarity.toFixed(1)}%`}
>
{seg.text}
</mark>
)
})}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { MatchResult } from '../api/client'
const METHOD_LABEL: Record<string, string> = {
exact: 'Точное',
fuzzy: 'Нечёткое',
semantic: 'Семантика',
}
const METHOD_COLOR: Record<string, string> = {
exact: 'bg-red-100 text-red-700 border-red-200',
fuzzy: 'bg-orange-100 text-orange-700 border-orange-200',
semantic: 'bg-yellow-100 text-yellow-700 border-yellow-200',
}
const HIGHLIGHT_COLOR: Record<string, string> = {
exact: 'bg-red-200',
fuzzy: 'bg-orange-200',
semantic: 'bg-yellow-200',
}
interface Props {
match: MatchResult
index: number
active: boolean
onClick: () => void
}
export { HIGHLIGHT_COLOR }
export default function MatchCard({ match, index, active, onClick }: Props) {
const colorClass = METHOD_COLOR[match.method] ?? 'bg-gray-100 text-gray-700 border-gray-200'
return (
<button
onClick={onClick}
className={[
'w-full text-left border rounded-xl p-3 transition-all',
active ? 'ring-2 ring-blue-400 shadow-md' : 'hover:shadow-sm',
colorClass,
].join(' ')}
>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-semibold uppercase tracking-wide">
{METHOD_LABEL[match.method] ?? match.method}
</span>
<span className="text-sm font-bold">{match.similarity.toFixed(1)}%</span>
</div>
<p className="text-sm font-medium truncate">
{match.source_title ?? match.source_id ?? '—'}
</p>
{match.source_db && (
<p className="text-xs opacity-70">{match.source_db}</p>
)}
{match.fragment_text && (
<p className="mt-1 text-xs italic opacity-80 line-clamp-2">
«{match.fragment_text}»
</p>
)}
{match.url && (
<a
href={match.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="mt-1 text-xs underline opacity-60 block truncate"
>
{match.url}
</a>
)}
</button>
)
}

View File

@@ -0,0 +1,33 @@
interface Props {
value: number
}
function color(v: number) {
if (v < 20) return 'bg-green-500'
if (v < 50) return 'bg-yellow-400'
return 'bg-red-500'
}
function label(v: number) {
if (v < 20) return 'Оригинально'
if (v < 50) return 'Умеренное сходство'
return 'Высокое заимствование'
}
export default function SimilarityMeter({ value }: Props) {
const pct = Math.min(Math.max(value, 0), 100)
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm font-medium">
<span>{label(pct)}</span>
<span className="text-gray-700">{pct.toFixed(1)}%</span>
</div>
<div className="w-full h-3 rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${color(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react'
import {
getIndexSources, startDownload, startUpdate,
getJobStatus, openIndexSocket,
IndexSource, JobStatus,
} from '../api/client'
const STATUS_LABEL: Record<string, string> = {
idle: 'Ожидание',
running: 'Выполняется',
done: 'Готово',
error: 'Ошибка',
started: 'Запуск…',
}
const STATUS_COLOR: Record<string, string> = {
idle: 'text-gray-400',
running: 'text-blue-600',
done: 'text-green-600',
error: 'text-red-600',
started: 'text-blue-500',
}
const SOURCE_SIZE: Record<string, string> = {
wikipedia_ru: '~8 GB',
wikipedia_en: '~22 GB',
}
const SOURCE_DESC: Record<string, string> = {
wikipedia_ru: 'Русскоязычные статьи Википедии',
wikipedia_en: 'Англоязычные статьи Википедии',
}
export default function AdminPage() {
const [sources, setSources] = useState<IndexSource[]>([])
const [job, setJob] = useState<JobStatus>({ status: 'idle', progress: 0, message: '', docs_done: 0 })
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Load sources + current status
useEffect(() => {
getIndexSources().then(setSources).catch(() => {})
getJobStatus().then(setJob).catch(() => {})
}, [])
// Subscribe to WebSocket progress
useEffect(() => {
const close = openIndexSocket((ev) => {
setJob(ev)
if (ev.status !== 'running') setBusy(false)
})
return close
}, [])
const isRunning = job.status === 'running' || busy
const handleDownload = async (key: string) => {
setError(null)
setBusy(true)
try {
const j = await startDownload(key)
setJob(j)
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail ?? 'Ошибка запуска')
setBusy(false)
}
}
const handleUpdate = async (key: string) => {
setError(null)
setBusy(true)
try {
const j = await startUpdate(key)
setJob(j)
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail ?? 'Ошибка запуска')
setBusy(false)
}
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-2xl mx-auto flex flex-col gap-6">
<h1 className="text-xl font-bold text-gray-800">Управление базой документов</h1>
{/* Current job status */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Статус задания</span>
<span className={`text-sm font-medium ${STATUS_COLOR[job.status] ?? 'text-gray-500'}`}>
{STATUS_LABEL[job.status] ?? job.status}
{job.label ? `${job.label}` : ''}
</span>
</div>
{job.message && (
<p className="text-sm text-gray-500">{job.message}</p>
)}
{isRunning && (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-xs text-gray-400">
<span>{job.docs_done > 0 ? `Проиндексировано: ${job.docs_done.toLocaleString()} документов` : ''}</span>
<span>{job.progress}%</span>
</div>
<div className="w-full h-2.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{job.status === 'done' && job.finished_at && (
<p className="text-xs text-gray-400">
Завершено: {new Date(job.finished_at).toLocaleString('ru-RU')}
</p>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 text-red-600 text-sm">
{error}
</div>
)}
{/* Source cards */}
<div className="flex flex-col gap-3">
{sources.map((src) => (
<div
key={src.key}
className="bg-white rounded-2xl shadow-sm border border-gray-100 p-5 flex items-center gap-4"
>
<div className="flex-1">
<p className="font-semibold text-gray-800">{src.label}</p>
<p className="text-sm text-gray-500">{SOURCE_DESC[src.key] ?? ''}</p>
<p className="text-xs text-gray-400 mt-0.5">{SOURCE_SIZE[src.key] ?? ''}</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<button
onClick={() => handleDownload(src.key)}
disabled={isRunning}
className="text-sm bg-blue-600 text-white px-4 py-2 rounded-xl hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Скачать дамп и проиндексировать с нуля"
>
Скачать и индексировать
</button>
<button
onClick={() => handleUpdate(src.key)}
disabled={isRunning}
className="text-sm border border-gray-200 px-4 py-2 rounded-xl hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Добавить только новые документы (дамп уже скачан)"
>
Обновить базу
</button>
</div>
</div>
))}
</div>
<div className="text-xs text-gray-400 bg-gray-100 rounded-xl p-4 flex flex-col gap-1">
<p><strong>Скачать и индексировать</strong> скачивает свежий дамп (~часы) и строит индекс с нуля.</p>
<p><strong>Обновить базу</strong> использует уже скачанный дамп, добавляет только документы, которых ещё нет в базе. Быстро после первой загрузки.</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useState } from 'react'
import { login, register } from '../api/client'
interface Props {
onAuth: (token: string) => void
}
export default function AuthPage({ onAuth }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handle = async () => {
setError(null)
setLoading(true)
try {
if (mode === 'register') {
await register(email, password)
}
const token = await login(email, password)
localStorage.setItem('token', token)
onAuth(token)
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail ?? 'Ошибка')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-md p-8 w-full max-w-sm flex flex-col gap-5">
<h1 className="text-2xl font-bold text-center text-gray-800">Антиплагиат</h1>
<div className="flex rounded-xl overflow-hidden border text-sm font-medium">
{(['login', 'register'] as const).map((m) => (
<button
key={m}
onClick={() => { setMode(m); setError(null) }}
className={`flex-1 py-2 transition-colors ${mode === m ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`}
>
{m === 'login' ? 'Вход' : 'Регистрация'}
</button>
))}
</div>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-blue-400"
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handle()}
className="border rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-blue-400"
/>
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<button
onClick={handle}
disabled={loading || !email || !password}
className="py-2.5 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-40 transition-colors"
>
{loading ? '…' : mode === 'login' ? 'Войти' : 'Зарегистрироваться'}
</button>
<button
onClick={() => onAuth('')}
className="text-xs text-gray-400 hover:text-gray-600 text-center"
>
Продолжить без входа
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { listReports, downloadReportPdf, ReportSummary } from '../api/client'
interface Props {
onOpen: (taskId: string) => void
onNewCheck: () => void
}
function statusBadge(status: string) {
const map: Record<string, string> = {
completed: 'bg-green-100 text-green-700',
processing: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-500',
}
const label: Record<string, string> = {
completed: 'Готово',
processing: 'Анализ…',
failed: 'Ошибка',
pending: 'Ожидание',
}
return (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${map[status] ?? 'bg-gray-100 text-gray-500'}`}>
{label[status] ?? status}
</span>
)
}
function similarityColor(v?: number | null) {
if (v == null) return 'text-gray-400'
if (v < 20) return 'text-green-600'
if (v < 50) return 'text-yellow-600'
return 'text-red-600'
}
export default function HistoryPage({ onOpen, onNewCheck }: Props) {
const [tasks, setTasks] = useState<ReportSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
listReports()
.then(setTasks)
.catch(() => setError('Не удалось загрузить историю'))
.finally(() => setLoading(false))
}, [])
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<h1 className="font-semibold text-gray-800">История проверок</h1>
<button
onClick={onNewCheck}
className="text-sm bg-blue-600 text-white px-4 py-2 rounded-xl hover:bg-blue-700 transition-colors"
>
+ Новая проверка
</button>
</header>
<div className="flex-1 p-6 max-w-3xl mx-auto w-full">
{loading && (
<div className="text-center text-gray-400 mt-12 animate-pulse">Загрузка</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-red-600 text-sm">
{error}
</div>
)}
{!loading && !error && tasks.length === 0 && (
<div className="text-center text-gray-400 mt-12">
<p className="text-lg">Проверок пока нет</p>
<button onClick={onNewCheck} className="mt-4 text-blue-600 hover:underline text-sm">
Загрузить первый документ
</button>
</div>
)}
{tasks.length > 0 && (
<ul className="flex flex-col gap-3">
{tasks.map((t) => (
<li
key={t.task_id}
className="bg-white rounded-2xl shadow-sm border border-gray-100 px-5 py-4 flex items-center gap-4"
>
{/* Similarity circle */}
<div className={`text-2xl font-bold w-16 text-center shrink-0 ${similarityColor(t.overall_similarity)}`}>
{t.overall_similarity != null ? `${t.overall_similarity}%` : '—'}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{statusBadge(t.status)}
<span className="text-xs text-gray-400">
{t.created_at ? new Date(t.created_at).toLocaleString('ru-RU') : ''}
</span>
</div>
<p className="text-xs text-gray-400 truncate font-mono">{t.task_id}</p>
</div>
{/* Actions */}
<div className="flex gap-2 shrink-0">
{t.status === 'completed' && (
<button
onClick={() => { downloadReportPdf(t.task_id).catch(console.error) }}
className="text-xs border border-gray-200 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors"
title="Скачать PDF"
>
PDF
</button>
)}
<button
onClick={() => onOpen(t.task_id)}
className="text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded-lg px-3 py-1.5 hover:bg-blue-100 transition-colors"
>
Открыть
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useState } from 'react'
import DropZone from '../components/DropZone'
import { uploadDocument } from '../api/client'
interface Props {
onTaskCreated: (taskId: string) => void
}
export default function Home({ onTaskCreated }: Props) {
const [file, setFile] = useState<File | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!file) return
setLoading(true)
setError(null)
try {
const res = await uploadDocument(file)
onTaskCreated(res.task_id)
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Ошибка загрузки'
setError(msg)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-md p-8 w-full max-w-lg flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800 text-center">Антиплагиат</h1>
<p className="text-sm text-gray-500 text-center">
Загрузите документ для проверки на заимствования
</p>
<DropZone onFile={setFile} disabled={loading} />
{error && (
<p className="text-red-500 text-sm text-center">{error}</p>
)}
<button
onClick={handleSubmit}
disabled={!file || loading}
className="w-full py-3 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Загрузка…' : 'Проверить'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { getReport, openProgressSocket, downloadReportPdf, ReportResponse, MatchResult, ProgressEvent } from '../api/client'
import SimilarityMeter from '../components/SimilarityMeter'
import MatchCard from '../components/MatchCard'
import HighlightedText from '../components/HighlightedText'
interface Props {
taskId: string
onBack: () => void
}
const STAGE_LABEL: Record<string, string> = {
fingerprint: 'Поиск точных совпадений…',
fuzzy: 'Нечёткое сравнение…',
semantic: 'Семантический анализ…',
saving: 'Сохранение результатов…',
done: 'Готово',
failed: 'Ошибка',
}
const POLL_INTERVAL = 4000
export default function Report({ taskId, onBack }: Props) {
const [report, setReport] = useState<ReportResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<ProgressEvent | null>(null)
const [activeMatch, setActiveMatch] = useState<number | null>(null)
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchReport = useCallback(async () => {
try {
const data = await getReport(taskId)
setReport(data)
return data.status
} catch {
setError('Не удалось загрузить отчёт')
return 'failed'
}
}, [taskId])
// WebSocket progress + fallback polling
useEffect(() => {
const closeWs = openProgressSocket(taskId, (ev) => {
setProgress(ev)
if (ev.stage === 'done' || ev.stage === 'failed') {
fetchReport()
}
})
// Polling fallback if WS doesn't deliver done
const poll = async () => {
const s = await fetchReport()
if (s === 'processing') {
pollRef.current = setTimeout(poll, POLL_INTERVAL)
}
}
poll()
return () => {
closeWs()
if (pollRef.current) clearTimeout(pollRef.current)
}
}, [taskId, fetchReport])
const isProcessing = !report || report.status === 'processing'
const matches: MatchResult[] = report?.matches ?? []
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Новая проверка
</button>
<h1 className="font-semibold text-gray-800">Отчёт о проверке</h1>
<div className="flex items-center gap-3">
{report?.status === 'completed' && (
<button
onClick={() => { downloadReportPdf(taskId).catch(console.error) }}
className="text-xs border border-gray-200 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors"
>
Скачать PDF
</button>
)}
<span className="text-xs text-gray-400">{taskId.slice(0, 8)}</span>
</div>
</header>
{/* Progress bar */}
{isProcessing && (
<div className="bg-white border-b px-6 py-3">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>{progress ? STAGE_LABEL[progress.stage] : 'Запуск анализа…'}</span>
<span>{progress?.progress ?? 0}%</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-500"
style={{ width: `${progress?.progress ?? 0}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-400 mt-1">
{['fingerprint', 'fuzzy', 'semantic', 'saving', 'done'].map((stage) => (
<span
key={stage}
className={progress && ['fingerprint','fuzzy','semantic','saving','done'].indexOf(stage) <=
['fingerprint','fuzzy','semantic','saving','done'].indexOf(progress.stage)
? 'text-blue-600 font-medium' : ''}
>
{STAGE_LABEL[stage].replace('…', '')}
</span>
))}
</div>
</div>
)}
{error && (
<div className="m-4 bg-red-50 border border-red-200 rounded-xl p-4 text-red-600 text-sm">
{error}
</div>
)}
{/* Main content */}
{report && report.status === 'completed' && (
<>
{/* Summary bar */}
<div className="bg-white border-b px-6 py-4">
<div className="max-w-4xl mx-auto">
<SimilarityMeter value={report.overall_similarity ?? 0} />
</div>
</div>
{/* Legend */}
<div className="bg-white border-b px-6 py-2 flex gap-4 text-xs text-gray-600">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-200 inline-block" /> Точное совпадение</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-orange-200 inline-block" /> Нечёткое</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-yellow-200 inline-block" /> Семантика</span>
</div>
{/* Two-column layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left: highlighted document text */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto bg-white rounded-2xl shadow-sm p-6 min-h-full">
{report.source_text ? (
<HighlightedText
text={report.source_text}
matches={matches}
activeIndex={activeMatch}
onMatchClick={setActiveMatch}
/>
) : (
<p className="text-gray-400 text-sm">Текст документа недоступен</p>
)}
</div>
</div>
{/* Right: match list */}
<aside className="w-80 shrink-0 overflow-y-auto border-l bg-gray-50 p-4 flex flex-col gap-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Совпадения ({matches.length})
</p>
{matches.length === 0 && (
<p className="text-sm text-gray-400 text-center mt-8">
Заимствований не обнаружено
</p>
)}
{matches.map((m, i) => (
<MatchCard
key={i}
match={m}
index={i}
active={activeMatch === i}
onClick={() => setActiveMatch(activeMatch === i ? null : i)}
/>
))}
</aside>
</div>
</>
)}
{/* Still processing with no result yet */}
{isProcessing && !error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400">
<div className="text-4xl mb-3 animate-spin"></div>
<p className="text-sm">Анализируем документ</p>
</div>
</div>
)}
{report?.status === 'failed' && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-red-500">
<p className="text-lg font-semibold">Анализ завершился с ошибкой</p>
<button onClick={onBack} className="mt-4 text-sm text-blue-600 hover:underline">
Попробовать снова
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})