feat: frontend pages — recordings, protocols, templates, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onUpload: (file: File, title: string) => Promise<unknown>
|
||||||
|
isUploading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileUpload({ onUpload, isUploading }: Props) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const file = fileRef.current?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
await onUpload(file, title || file.name)
|
||||||
|
setTitle('')
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<input ref={fileRef} type="file" accept="audio/*,video/*" className="text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Название (необязательно)"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isUploading ? 'Загрузка...' : 'Загрузить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
uploaded: 'bg-gray-100 text-gray-700',
|
||||||
|
processing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
done: 'bg-green-100 text-green-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
generating: 'bg-blue-100 text-blue-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
uploaded: 'Загружено',
|
||||||
|
processing: 'Обработка...',
|
||||||
|
done: 'Готово',
|
||||||
|
error: 'Ошибка',
|
||||||
|
generating: 'Генерация...',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[status] ?? ''}`}>
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useProtocol } from '../hooks/useProtocols'
|
||||||
|
import { useTemplates } from '../hooks/useTemplates'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
date_participants_topic: 'Дата, участники, тема встречи',
|
||||||
|
process_description: 'Описание обследуемого процесса',
|
||||||
|
current_state: 'Текущее состояние (as-is)',
|
||||||
|
problems: 'Выявленные проблемы / узкие места',
|
||||||
|
agreements: 'Договорённости и решения',
|
||||||
|
open_questions: 'Открытые вопросы',
|
||||||
|
tasks: 'Задачи с ответственными и сроками',
|
||||||
|
next_steps: 'Следующие шаги',
|
||||||
|
client_goals: 'Верхнеуровневые цели клиента',
|
||||||
|
project_parameters: 'Параметры будущего проекта',
|
||||||
|
budget_constraints: 'Бюджетные ограничения',
|
||||||
|
time_constraints: 'Ограничения по срокам',
|
||||||
|
decision_makers: 'ЛПР',
|
||||||
|
beneficiaries: 'Бенефициары',
|
||||||
|
influencers: 'Лица, влияющие на принятие решения',
|
||||||
|
discussed_topics: 'Обсуждённые вопросы',
|
||||||
|
decisions: 'Принятые решения',
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) =>
|
||||||
|
typeof item === 'object' ? Object.entries(item as Record<string, unknown>).map(([k, v]) => `${k}: ${v}`).join(', ') : String(item),
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
return String(value ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportProtocol(protocolId: string, format: string) {
|
||||||
|
const resp = await api.post(`/exports/protocol/${protocolId}`, { format }, { responseType: 'json' })
|
||||||
|
const exportId = resp.data.id
|
||||||
|
window.open(`/api/exports/${exportId}/download`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtocolDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { data: protocol, isLoading } = useProtocol(id!)
|
||||||
|
const { templates: _templates } = useTemplates()
|
||||||
|
|
||||||
|
if (isLoading || !protocol) return <p>Загрузка...</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Протокол</h1>
|
||||||
|
<StatusBadge status={protocol.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{(['docx', 'pdf', 'md'] as const).map((fmt) => (
|
||||||
|
<button
|
||||||
|
key={fmt}
|
||||||
|
onClick={() => exportProtocol(protocol.id, fmt)}
|
||||||
|
className="bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{fmt.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{protocol.status === 'done' && protocol.content && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(protocol.content).map(([key, value]) => (
|
||||||
|
<div key={key} className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<h3 className="font-semibold text-gray-700 mb-2">
|
||||||
|
{SECTION_LABELS[key] ?? key}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 whitespace-pre-line">{renderValue(value)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{protocol.status === 'generating' && (
|
||||||
|
<div className="bg-yellow-50 p-6 rounded-lg text-center">
|
||||||
|
<p className="text-yellow-800">Протокол генерируется, подождите...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{protocol.status === 'error' && (
|
||||||
|
<div className="bg-red-50 p-6 rounded-lg text-center">
|
||||||
|
<p className="text-red-800">Ошибка при генерации протокола</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import { useProtocols } from '../hooks/useProtocols'
|
||||||
|
|
||||||
|
export default function ProtocolsPage() {
|
||||||
|
const { protocols, isLoading } = useProtocols()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Протоколы</h1>
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">ID</th>
|
||||||
|
<th className="text-left px-4 py-3">Модель</th>
|
||||||
|
<th className="text-left px-4 py-3">Статус</th>
|
||||||
|
<th className="text-left px-4 py-3">Дата</th>
|
||||||
|
<th className="text-left px-4 py-3">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{protocols.map((p) => (
|
||||||
|
<tr key={p.id} className="border-t">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{p.id.slice(0, 8)}</td>
|
||||||
|
<td className="px-4 py-3">{p.llm_model || '—'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
|
||||||
|
<td className="px-4 py-3">{new Date(p.created_at).toLocaleDateString('ru')}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link to={`/protocols/${p.id}`} className="text-blue-600 hover:underline">
|
||||||
|
Открыть
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import FileUpload from '../components/FileUpload'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import { useRecordings } from '../hooks/useRecordings'
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null) {
|
||||||
|
if (!seconds) return '—'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = { web: '🌐 Web', telegram: '💬 Telegram', lensa: '💬 Lensa' }
|
||||||
|
|
||||||
|
export default function RecordingsPage() {
|
||||||
|
const { recordings, isLoading, upload, isUploading, deleteRecording } = useRecordings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Записи</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<FileUpload onUpload={upload} isUploading={isUploading} />
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Название</th>
|
||||||
|
<th className="text-left px-4 py-3">Источник</th>
|
||||||
|
<th className="text-left px-4 py-3">Размер</th>
|
||||||
|
<th className="text-left px-4 py-3">Длительность</th>
|
||||||
|
<th className="text-left px-4 py-3">Статус</th>
|
||||||
|
<th className="text-left px-4 py-3">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recordings.map((rec) => (
|
||||||
|
<tr key={rec.id} className="border-t">
|
||||||
|
<td className="px-4 py-3">{rec.title}</td>
|
||||||
|
<td className="px-4 py-3">{SOURCE_LABELS[rec.source] ?? rec.source}</td>
|
||||||
|
<td className="px-4 py-3">{formatSize(rec.file_size)}</td>
|
||||||
|
<td className="px-4 py-3">{formatDuration(rec.duration)}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={rec.status} /></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteRecording(rec.id)}
|
||||||
|
className="text-red-500 hover:text-red-700 text-xs"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import api from '../api/client'
|
||||||
|
import type { StorageInfo } from '../types'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [retentionDays, setRetentionDays] = useState(90)
|
||||||
|
const [cleanupStarted, setCleanupStarted] = useState(false)
|
||||||
|
|
||||||
|
const { data: storage } = useQuery<StorageInfo>({
|
||||||
|
queryKey: ['storage'],
|
||||||
|
queryFn: () => api.get('/settings/storage').then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCleanup = async () => {
|
||||||
|
await api.post('/settings/cleanup', null, { params: { retention_days: retentionDays } })
|
||||||
|
setCleanupStarted(true)
|
||||||
|
setTimeout(() => setCleanupStarted(false), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Настройки</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-3">Профиль</h3>
|
||||||
|
<p className="text-sm text-gray-600">Пользователь: <strong>{user?.username}</strong></p>
|
||||||
|
<p className="text-sm text-gray-600">Email: <strong>{user?.email}</strong></p>
|
||||||
|
<p className="text-sm text-gray-600">Роль: <strong>{user?.role}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-3">Хранилище</h3>
|
||||||
|
{storage && (
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<p>Записи: <strong>{(storage.uploads_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ</strong></p>
|
||||||
|
<p>Экспорт: <strong>{(storage.exports_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ</strong></p>
|
||||||
|
<p>Итого: <strong>{storage.total_gb} ГБ</strong></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-3">Очистка данных</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm text-gray-600">Удалить записи старше</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
||||||
|
className="border rounded px-2 py-1 w-20 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600">дней</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCleanup}
|
||||||
|
className="bg-red-600 text-white px-3 py-1.5 rounded text-sm hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{cleanupStarted && <p className="text-green-600 text-sm mt-2">Очистка запущена!</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTemplates } from '../hooks/useTemplates'
|
||||||
|
|
||||||
|
export default function TemplatesPage() {
|
||||||
|
const { templates, isLoading, createTemplate, deleteTemplate } = useTemplates()
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [form, setForm] = useState({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' as const })
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
await createTemplate(form)
|
||||||
|
setShowForm(false)
|
||||||
|
setForm({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Шаблоны протоколов</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ Новый шаблон
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm mb-6 space-y-3">
|
||||||
|
<input
|
||||||
|
placeholder="Название"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Описание"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="System prompt"
|
||||||
|
value={form.system_prompt}
|
||||||
|
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm h-24"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="User prompt (используйте {transcription} для вставки текста)"
|
||||||
|
value={form.user_prompt}
|
||||||
|
onChange={(e) => setForm({ ...form, user_prompt: e.target.value })}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm h-24"
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreate} className="bg-green-600 text-white px-4 py-2 rounded text-sm">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<div key={tpl.id} className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{tpl.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{tpl.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{tpl.type}</span>
|
||||||
|
{tpl.is_default && <span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">По умолчанию</span>}
|
||||||
|
{tpl.type === 'custom' && (
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTemplate(tpl.id)}
|
||||||
|
className="text-red-500 text-xs hover:text-red-700"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user