This commit is contained in:
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
52
frontend/nginx.conf
Normal 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
29
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
98
frontend/src/App.tsx
Normal file
98
frontend/src/App.tsx
Normal 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
160
frontend/src/api/client.ts
Normal 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)
|
||||
}
|
||||
56
frontend/src/components/DropZone.tsx
Normal file
56
frontend/src/components/DropZone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
frontend/src/components/HighlightedText.tsx
Normal file
109
frontend/src/components/HighlightedText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/MatchCard.tsx
Normal file
76
frontend/src/components/MatchCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/SimilarityMeter.tsx
Normal file
33
frontend/src/components/SimilarityMeter.tsx
Normal 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
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
172
frontend/src/pages/AdminPage.tsx
Normal file
172
frontend/src/pages/AdminPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/pages/AuthPage.tsx
Normal file
85
frontend/src/pages/AuthPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/HistoryPage.tsx
Normal file
128
frontend/src/pages/HistoryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
frontend/src/pages/Home.tsx
Normal file
53
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
frontend/src/pages/Report.tsx
Normal file
206
frontend/src/pages/Report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal 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
15
frontend/vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user