Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env), локально в докере остаётся только Elasticsearch + app-сервисы. Старый docker-compose.yml (полностью автономный стек) сохранён как docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS, собирающий фронтенд в статику). Makefile переписан под три режима (dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
188 lines
7.1 KiB
TypeScript
188 lines
7.1 KiB
TypeScript
import { useState } from 'react';
|
||
import { useMutation } from '@tanstack/react-query';
|
||
import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import { GostCitation } from '../components/GostCitation';
|
||
import { useBibliographyStore } from '../store/bibliography';
|
||
import { api } from '../api/client';
|
||
import type { GostResultData } from '../types';
|
||
|
||
export function Bibliography() {
|
||
const { sources, removeSource, clearSources } = useBibliographyStore();
|
||
const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1');
|
||
const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null);
|
||
|
||
const formatMutation = useMutation({
|
||
mutationFn: async () => {
|
||
// Создать задачу GOST форматирования
|
||
const task = await api.post('/tasks/', {
|
||
type: 'gost',
|
||
input_data: {
|
||
doc_ids: sources.map((s) => s.id),
|
||
style,
|
||
},
|
||
});
|
||
const taskId = task.data.id;
|
||
|
||
// Диспатчить задачу
|
||
await api.post('/gost/format', {
|
||
task_id: taskId,
|
||
doc_ids: sources.map((s) => s.id),
|
||
style,
|
||
});
|
||
|
||
// Простое форматирование прямо на клиенте (используем ГОСТ-цитаты из источников)
|
||
return {
|
||
bibliography: sources.map((s, i) => ({
|
||
number: i + 1,
|
||
citation: s.gost_citation,
|
||
doc_id: s.id,
|
||
})),
|
||
style,
|
||
total: sources.length,
|
||
} as GostResultData;
|
||
},
|
||
onSuccess: (data) => {
|
||
setFormattedResult(data);
|
||
toast.success('Библиография отформатирована');
|
||
},
|
||
onError: () => {
|
||
// При ошибке используем локальные ГОСТ-цитаты
|
||
setFormattedResult({
|
||
bibliography: sources.map((s, i) => ({
|
||
number: i + 1,
|
||
citation: s.gost_citation,
|
||
doc_id: s.id,
|
||
})),
|
||
style,
|
||
total: sources.length,
|
||
});
|
||
},
|
||
});
|
||
|
||
const handleFormat = () => {
|
||
if (sources.length === 0) {
|
||
toast.error('Добавьте источники в библиографию');
|
||
return;
|
||
}
|
||
formatMutation.mutate();
|
||
};
|
||
|
||
const handleCopyAll = () => {
|
||
if (!formattedResult) return;
|
||
const text = formattedResult.bibliography
|
||
.map((e) => `${e.number}. ${e.citation}`)
|
||
.join('\n');
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
toast.success('Список литературы скопирован');
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-3xl mx-auto space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Список литературы</h1>
|
||
<p className="text-gray-500">
|
||
Добавляйте источники из результатов поиска и форматируйте библиографию по ГОСТ.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Источники в списке */}
|
||
{sources.length > 0 ? (
|
||
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
|
||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
Источников: {sources.length}
|
||
</span>
|
||
<button
|
||
onClick={clearSources}
|
||
className="text-xs text-red-500 hover:text-red-700 flex items-center gap-1"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
Очистить всё
|
||
</button>
|
||
</div>
|
||
<div className="divide-y divide-gray-100">
|
||
{sources.map((source) => (
|
||
<div key={source.id} className="flex items-start gap-3 px-5 py-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-800 truncate">{source.title}</p>
|
||
<p className="text-xs text-gray-400 mt-0.5">
|
||
{source.authors.slice(0, 2).map((a) => a.last_name).join(', ')}
|
||
{source.year && ` · ${source.year}`}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => removeSource(source.id)}
|
||
className="p-1 text-gray-300 hover:text-red-400 transition-colors flex-shrink-0"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="bg-gray-50 rounded-xl border border-dashed border-gray-200 p-10 text-center">
|
||
<BookMarked className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||
<p className="text-gray-400 text-sm">
|
||
Нажмите «В библиографию» на любом источнике из результатов поиска
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Настройки форматирования */}
|
||
{sources.length > 0 && (
|
||
<div className="flex items-center gap-4">
|
||
<div>
|
||
<label className="text-sm text-gray-500 mr-2">Стиль ГОСТ:</label>
|
||
<select
|
||
value={style}
|
||
onChange={(e) => {
|
||
setStyle(e.target.value as '7.1' | '7.0.5');
|
||
setFormattedResult(null);
|
||
}}
|
||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||
>
|
||
<option value="7.1">ГОСТ 7.1-2003 (полная)</option>
|
||
<option value="7.0.5">ГОСТ Р 7.0.5-2008 (краткая)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleFormat}
|
||
disabled={formatMutation.isPending}
|
||
className="flex items-center gap-2 px-5 py-2.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors ml-auto"
|
||
>
|
||
<BookOpen className="w-4 h-4" />
|
||
{formatMutation.isPending ? 'Форматирование...' : 'Сформировать список'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Результат */}
|
||
{formattedResult && (
|
||
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
|
||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
Список литературы · ГОСТ {style}
|
||
</span>
|
||
<button
|
||
onClick={handleCopyAll}
|
||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
Копировать всё
|
||
</button>
|
||
</div>
|
||
<div className="px-2 py-2 divide-y divide-gray-50">
|
||
{formattedResult.bibliography.map((entry) => (
|
||
<GostCitation key={entry.doc_id} citation={entry.citation} number={entry.number} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|