fixed proxy
This commit is contained in:
@@ -32,9 +32,11 @@ from app.schemas import (
|
||||
LlmMessageOut,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
PreviewPdfRequest,
|
||||
ProjectCreate,
|
||||
ProjectOut,
|
||||
ProjectUpdate,
|
||||
ProtectPagesRequest,
|
||||
TableRowsPatch,
|
||||
TableRowsResponse,
|
||||
TableSettingsUpdate,
|
||||
@@ -42,7 +44,8 @@ from app.schemas import (
|
||||
from app.services.designators import DEFAULT_MAPPINGS
|
||||
from app.services.excel_export import export_xlsx
|
||||
from app.services.llm import chat_edit_table, estimate_chat_context
|
||||
from app.services.pdf_export import export_pdf
|
||||
from app.services.pdf_export import export_pdf, export_pdf_page
|
||||
from app.services.page_breaks import protect_page_breaks
|
||||
from app.services.project_service import get_project_full, ingest_zip, project_dir
|
||||
from app.services.project_settings import (
|
||||
TABLE_SETTINGS_META,
|
||||
@@ -512,6 +515,53 @@ def generate(
|
||||
return TableRowsResponse(table_type=table_type, rows=rows)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/tables/{table_type}/protect-pages", response_model=TableRowsResponse)
|
||||
def protect_table_pages(
|
||||
project_id: int,
|
||||
table_type: str,
|
||||
body: ProtectPagesRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_project(db, project_id)
|
||||
if table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, "Unknown table type")
|
||||
body = body or ProtectPagesRequest()
|
||||
source = body.rows if body.rows is not None else get_rows(db, project_id, table_type)
|
||||
protected = protect_page_breaks(source, table_type)
|
||||
return TableRowsResponse(table_type=table_type, rows=protected)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/preview-pdf")
|
||||
def preview_pdf(project_id: int, body: PreviewPdfRequest, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
if body.table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, "Unknown table type")
|
||||
rows = body.rows if body.rows is not None else get_rows(db, project_id, body.table_type)
|
||||
inscriptions = get_inscriptions(db, project_id)
|
||||
params = get_project_params_map(db, project_id)
|
||||
resolved_inscriptions = resolve_inscriptions(inscriptions, params)
|
||||
try:
|
||||
stretch = get_font_stretch(db, project_id, body.table_type)
|
||||
result = export_pdf_page(
|
||||
body.table_type,
|
||||
rows,
|
||||
page=body.page,
|
||||
inscriptions=resolved_inscriptions,
|
||||
font_stretch=stretch,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return Response(
|
||||
content=result.data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"X-Total-Pages": str(result.total_pages),
|
||||
"X-Page": str(result.page),
|
||||
"Content-Disposition": f'inline; filename="{project.name}_{body.table_type}_p{result.page}.pdf"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/export")
|
||||
def export_document(project_id: int, body: ExportRequest, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
|
||||
@@ -36,6 +36,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Total-Pages", "X-Page"],
|
||||
)
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
@@ -155,6 +155,16 @@ class TableRowsPatch(BaseModel):
|
||||
rows: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ProtectPagesRequest(BaseModel):
|
||||
rows: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class PreviewPdfRequest(BaseModel):
|
||||
table_type: str
|
||||
page: int = 1
|
||||
rows: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class GenerateTableRequest(BaseModel):
|
||||
name_field: Optional[str] = None
|
||||
tech_reserve_percent: Optional[float] = None
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Защита заголовков групп от «висячих» переносов на границе страницы (как в десктопе)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Совпадает с TABLE_LAYOUTS в pdf_export.py
|
||||
PAGE_LIMITS: dict[str, tuple[int, int]] = {
|
||||
"perechen": (25, 32),
|
||||
"specification_pcb": (26, 32),
|
||||
"specification": (26, 32),
|
||||
"vedomost": (24, 29),
|
||||
}
|
||||
|
||||
_TEXT_SKIP = frozenset({"id", "row_index", "page_number", "overflow_fields"})
|
||||
|
||||
|
||||
def assign_page_numbers(rows: list[dict[str, Any]], first: int, other: int) -> None:
|
||||
page = 1
|
||||
used = 0
|
||||
limit = first
|
||||
for i, row in enumerate(rows):
|
||||
if used >= limit:
|
||||
page += 1
|
||||
used = 0
|
||||
limit = other
|
||||
row["row_index"] = i
|
||||
row["page_number"] = page
|
||||
used += 1
|
||||
|
||||
|
||||
def _rows_on_page_before(rows: list[dict[str, Any]], idx: int, page: int) -> int:
|
||||
return sum(1 for j in range(idx) if rows[j].get("page_number") == page)
|
||||
|
||||
|
||||
def _group_continues_on_next_page(rows: list[dict[str, Any]], header_idx: int, page: int) -> bool:
|
||||
for i in range(header_idx + 1, len(rows)):
|
||||
r = rows[i]
|
||||
if r.get("is_header"):
|
||||
break
|
||||
if r.get("page_number") == page + 1 and not r.get("is_empty"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def needs_page_break_protection(
|
||||
rows: list[dict[str, Any]], row_index: int, first: int, other: int
|
||||
) -> tuple[bool, int]:
|
||||
"""Возвращает (нужна_защита, сколько_пустых_вставить_перед_заголовком)."""
|
||||
if row_index < 0 or row_index >= len(rows):
|
||||
return False, 0
|
||||
row = rows[row_index]
|
||||
if not row.get("is_header"):
|
||||
return False, 0
|
||||
|
||||
page = int(row.get("page_number") or 1)
|
||||
max_rows = first if page == 1 else other
|
||||
on_page = _rows_on_page_before(rows, row_index, page)
|
||||
|
||||
if on_page not in (max_rows - 1, max_rows - 2):
|
||||
return False, 0
|
||||
if not _group_continues_on_next_page(rows, row_index, page):
|
||||
return False, 0
|
||||
|
||||
empty_count = 2 if on_page == max_rows - 2 else 1
|
||||
return True, empty_count
|
||||
|
||||
|
||||
def _make_empty_row(sample: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
empty: dict[str, Any] = {
|
||||
"is_empty": True,
|
||||
"is_header": False,
|
||||
"is_underline": False,
|
||||
"page_number": 1,
|
||||
"row_index": 0,
|
||||
}
|
||||
if sample:
|
||||
for k, v in sample.items():
|
||||
if k in _TEXT_SKIP or k in empty:
|
||||
continue
|
||||
if isinstance(v, bool):
|
||||
empty[k] = False
|
||||
elif isinstance(v, (int, float)) and k not in ("id",):
|
||||
empty[k] = ""
|
||||
elif isinstance(v, str):
|
||||
empty[k] = ""
|
||||
elif v is None:
|
||||
empty[k] = ""
|
||||
return empty
|
||||
|
||||
|
||||
def protect_page_breaks(rows: list[dict[str, Any]], table_type: str) -> list[dict[str, Any]]:
|
||||
"""Вставляет 1–2 пустые строки перед orphan-заголовком; пересчитывает page_number."""
|
||||
if table_type not in PAGE_LIMITS or not rows:
|
||||
return rows
|
||||
|
||||
first, other = PAGE_LIMITS[table_type]
|
||||
working = [dict(r) for r in rows]
|
||||
sample = working[0] if working else None
|
||||
|
||||
# Повторяем, пока есть что защищать (вставки сдвигают границы страниц)
|
||||
for _ in range(len(working) + 2):
|
||||
assign_page_numbers(working, first, other)
|
||||
insert_at: list[tuple[int, int]] = []
|
||||
for i in range(len(working)):
|
||||
need, count = needs_page_break_protection(working, i, first, other)
|
||||
if need:
|
||||
insert_at.append((i, count))
|
||||
if not insert_at:
|
||||
break
|
||||
# С конца, чтобы индексы не сбивались
|
||||
for idx, count in reversed(insert_at):
|
||||
for _ in range(count):
|
||||
working.insert(idx, _make_empty_row(sample))
|
||||
else:
|
||||
assign_page_numbers(working, first, other)
|
||||
|
||||
assign_page_numbers(working, first, other)
|
||||
return working
|
||||
@@ -441,3 +441,44 @@ def export_pdf(
|
||||
|
||||
c.save()
|
||||
return PdfExportResult(data=buf.getvalue(), overflows=overflows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfPagePreview:
|
||||
data: bytes
|
||||
total_pages: int
|
||||
page: int
|
||||
|
||||
|
||||
def export_pdf_page(
|
||||
table_type: str,
|
||||
rows: list[dict[str, Any]],
|
||||
page: int = 1,
|
||||
inscriptions: dict[int, str] | None = None,
|
||||
font_stretch: int = 100,
|
||||
) -> PdfPagePreview:
|
||||
"""Рендер одной страницы PDF (для live-превью)."""
|
||||
if table_type == "simple_list":
|
||||
raise ValueError("PDF preview is not available for simple_list")
|
||||
if table_type not in TABLE_LAYOUTS:
|
||||
raise ValueError(f"Unknown table type: {table_type}")
|
||||
|
||||
layout = TABLE_LAYOUTS[table_type]
|
||||
inscriptions = inscriptions or {}
|
||||
pages = _paginate_rows(rows, layout.rows_first, layout.rows_other)
|
||||
total = max(1, len(pages))
|
||||
page = max(1, min(int(page or 1), total))
|
||||
page_rows = pages[page - 1]
|
||||
row_offset = 0
|
||||
for p in range(1, page):
|
||||
row_offset += layout.rows_first if p == 1 else layout.rows_other
|
||||
|
||||
overflows: list[dict[str, Any]] = []
|
||||
font = _ensure_font()
|
||||
buf = BytesIO()
|
||||
page_w, page_h = layout.page_size
|
||||
c = canvas.Canvas(buf, pagesize=layout.page_size)
|
||||
_draw_frame(c, page_w, page_h, page, total, inscriptions, table_type, font)
|
||||
_draw_table_page(c, layout, page_rows, page_h, font_stretch, row_offset, overflows)
|
||||
c.save()
|
||||
return PdfPagePreview(data=buf.getvalue(), total_pages=total, page=page)
|
||||
|
||||
@@ -417,7 +417,7 @@ def generate_specification(
|
||||
)
|
||||
)
|
||||
|
||||
return _paginate(rows, 27, 33)
|
||||
return _paginate(rows, 26, 32)
|
||||
|
||||
|
||||
def generate_vedomost(
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models import (
|
||||
VariantProperty,
|
||||
VedomostRow,
|
||||
)
|
||||
from app.services.page_breaks import protect_page_breaks
|
||||
from app.services.project_settings import (
|
||||
clear_cell_overflows,
|
||||
get_cell_overflows,
|
||||
@@ -248,6 +249,7 @@ def generate_table(
|
||||
boards_count=boards_count if boards_count is not None else sl["boards_count"],
|
||||
)
|
||||
|
||||
rows = protect_page_breaks(rows, table_type)
|
||||
return replace_rows(db, project.id, table_type, rows)
|
||||
|
||||
|
||||
|
||||
@@ -177,6 +177,38 @@ export const api = {
|
||||
request(`/api/projects/${id}/tables/${type}`, { method: "PATCH", json: { rows } }),
|
||||
generateTable: (id, type, body = {}) =>
|
||||
request(`/api/projects/${id}/tables/${type}/generate`, { method: "POST", json: body }),
|
||||
protectTablePages: (id, type, rows) =>
|
||||
request(`/api/projects/${id}/tables/${type}/protect-pages`, {
|
||||
method: "POST",
|
||||
json: rows != null ? { rows } : {},
|
||||
}),
|
||||
previewPdf: async (id, { table_type, page = 1, rows }, signal) => {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const res = await fetch(`/api/projects/${id}/preview-pdf`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ table_type, page, rows }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
detail = data.detail || JSON.stringify(data);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
const blob = await res.blob();
|
||||
return {
|
||||
blob,
|
||||
totalPages: Number(res.headers.get("X-Total-Pages") || 1),
|
||||
page: Number(res.headers.get("X-Page") || page),
|
||||
};
|
||||
},
|
||||
exportDoc: async (id, table_type, format) => {
|
||||
const blob = await request(`/api/projects/${id}/export`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Горизонтальный split: left | handle | right.
|
||||
* Доля левой панели (0–1) хранится в localStorage.
|
||||
*/
|
||||
export default function SplitPane({
|
||||
left,
|
||||
right,
|
||||
storageKey = "gost_tables_split",
|
||||
defaultRatio = 0.58,
|
||||
minPx = 280,
|
||||
className = "",
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [ratio, setRatio] = useState(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const n = raw != null ? Number(raw) : defaultRatio;
|
||||
return Number.isFinite(n) && n > 0.15 && n < 0.85 ? n : defaultRatio;
|
||||
} catch {
|
||||
return defaultRatio;
|
||||
}
|
||||
});
|
||||
const dragging = useRef(false);
|
||||
|
||||
const persist = useCallback(
|
||||
(r) => {
|
||||
setRatio(r);
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(r));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onMove(e) {
|
||||
if (!dragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const max = rect.width - minPx;
|
||||
const clamped = Math.min(max, Math.max(minPx, x));
|
||||
persist(clamped / rect.width);
|
||||
}
|
||||
function onUp() {
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [minPx, persist]);
|
||||
|
||||
return (
|
||||
<div className={`split-pane ${className}`.trim()} ref={containerRef}>
|
||||
<div className="split-pane-left" style={{ flex: `0 0 ${ratio * 100}%` }}>
|
||||
{left}
|
||||
</div>
|
||||
<div
|
||||
className="split-pane-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="split-pane-right">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
function cloneRows(rows) {
|
||||
return JSON.parse(JSON.stringify(rows || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Стек undo/redo для снимков rows.
|
||||
* push(currentRows, { mergeKey }) — сохраняет currentRows в past;
|
||||
* при том же mergeKey в окне debounce повторный push не создаёт новый снимок.
|
||||
*/
|
||||
export function useCommandHistory(limit = 100) {
|
||||
const pastRef = useRef([]);
|
||||
const futureRef = useRef([]);
|
||||
const mergeKeyRef = useRef(null);
|
||||
const debounceTimer = useRef(null);
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const bump = useCallback(() => setTick((t) => t + 1), []);
|
||||
|
||||
const clearMerge = useCallback(() => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
mergeKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
pastRef.current = [];
|
||||
futureRef.current = [];
|
||||
clearMerge();
|
||||
bump();
|
||||
}, [bump, clearMerge]);
|
||||
|
||||
const push = useCallback(
|
||||
(currentRows, { mergeKey } = {}) => {
|
||||
if (mergeKey && mergeKey === mergeKeyRef.current) {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
mergeKeyRef.current = null;
|
||||
debounceTimer.current = null;
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
pastRef.current = [...pastRef.current, cloneRows(currentRows)].slice(-limit);
|
||||
futureRef.current = [];
|
||||
|
||||
if (mergeKey) {
|
||||
mergeKeyRef.current = mergeKey;
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
mergeKeyRef.current = null;
|
||||
debounceTimer.current = null;
|
||||
}, 300);
|
||||
} else {
|
||||
clearMerge();
|
||||
}
|
||||
bump();
|
||||
},
|
||||
[bump, clearMerge, limit]
|
||||
);
|
||||
|
||||
const undo = useCallback(
|
||||
(currentRows) => {
|
||||
if (!pastRef.current.length) return null;
|
||||
clearMerge();
|
||||
const prev = pastRef.current[pastRef.current.length - 1];
|
||||
pastRef.current = pastRef.current.slice(0, -1);
|
||||
futureRef.current = [...futureRef.current, cloneRows(currentRows)];
|
||||
bump();
|
||||
return prev;
|
||||
},
|
||||
[bump, clearMerge]
|
||||
);
|
||||
|
||||
const redo = useCallback(
|
||||
(currentRows) => {
|
||||
if (!futureRef.current.length) return null;
|
||||
clearMerge();
|
||||
const next = futureRef.current[futureRef.current.length - 1];
|
||||
futureRef.current = futureRef.current.slice(0, -1);
|
||||
pastRef.current = [...pastRef.current, cloneRows(currentRows)].slice(-limit);
|
||||
bump();
|
||||
return next;
|
||||
},
|
||||
[bump, clearMerge, limit]
|
||||
);
|
||||
|
||||
return {
|
||||
push,
|
||||
undo,
|
||||
redo,
|
||||
reset,
|
||||
canUndo: pastRef.current.length > 0,
|
||||
canRedo: futureRef.current.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import SplitPane from "../components/SplitPane";
|
||||
import { useCommandHistory } from "../hooks/useCommandHistory";
|
||||
import EspdPanel from "./EspdPanel";
|
||||
|
||||
const TABLE_TYPES = [
|
||||
@@ -164,6 +166,37 @@ function formatBytes(bytes) {
|
||||
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function loadColWidths(tableType, columns) {
|
||||
const key = `gost_colwidths_${tableType}`;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const next = {};
|
||||
for (const [field] of columns) {
|
||||
next[field] = Number(parsed[field]) > 40 ? Number(parsed[field]) : Math.max(72, Math.floor(640 / columns.length));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const defaults = {};
|
||||
const each = Math.max(72, Math.floor(640 / Math.max(columns.length, 1)));
|
||||
for (const [field] of columns) defaults[field] = each;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function saveColWidths(tableType, widths) {
|
||||
try {
|
||||
localStorage.setItem(`gost_colwidths_${tableType}`, JSON.stringify(widths));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProjectPage() {
|
||||
const { id } = useParams();
|
||||
const projectId = Number(id);
|
||||
@@ -192,6 +225,17 @@ export default function ProjectPage() {
|
||||
singular_name: "",
|
||||
plural_name: "",
|
||||
});
|
||||
const { push: historyPush, undo: historyUndo, redo: historyRedo, reset: historyReset, canUndo, canRedo } =
|
||||
useCommandHistory(100);
|
||||
const rowsRef = useRef(rows);
|
||||
rowsRef.current = rows;
|
||||
const [colWidths, setColWidths] = useState({});
|
||||
const [previewPage, setPreviewPage] = useState(1);
|
||||
const [previewTotal, setPreviewTotal] = useState(1);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
const previewUrlRef = useRef("");
|
||||
|
||||
const uploading = uploadState && uploadState.phase !== "done";
|
||||
|
||||
@@ -211,7 +255,9 @@ export default function ProjectPage() {
|
||||
const loadTable = useCallback(async () => {
|
||||
const data = await api.getTable(projectId, tableType);
|
||||
setRows(data.rows || []);
|
||||
}, [projectId, tableType]);
|
||||
historyReset();
|
||||
setPreviewPage(1);
|
||||
}, [projectId, tableType, historyReset]);
|
||||
|
||||
const loadTableSettings = useCallback(async () => {
|
||||
const data = await api.getTableSettings(projectId, tableType);
|
||||
@@ -330,6 +376,84 @@ export default function ProjectPage() {
|
||||
[rows]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColWidths(loadColWidths(tableType, columns));
|
||||
}, [tableType, columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "tables" || tableType === "simple_list") {
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = "";
|
||||
setPreviewUrl("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
setPreviewLoading(true);
|
||||
setPreviewError("");
|
||||
try {
|
||||
const result = await api.previewPdf(
|
||||
projectId,
|
||||
{ table_type: tableType, page: previewPage, rows },
|
||||
controller.signal
|
||||
);
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
previewUrlRef.current = url;
|
||||
setPreviewUrl(url);
|
||||
setPreviewTotal(result.totalPages || 1);
|
||||
if (result.totalPages && previewPage > result.totalPages) {
|
||||
setPreviewPage(result.totalPages);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") return;
|
||||
setPreviewError(e.message || "Ошибка превью");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setPreviewLoading(false);
|
||||
}
|
||||
}, 700);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [rows, tableType, previewPage, projectId, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "tables") return;
|
||||
function onKeyDown(e) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
const target = e.target;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
const inChatField =
|
||||
(tag === "textarea" || (tag === "input" && !target.closest?.(".table-wrap"))) &&
|
||||
target.closest?.(".split-pane-right, .card");
|
||||
const inTableCell = target?.closest?.(".table-wrap");
|
||||
if (inChatField && !inTableCell) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const prev = historyUndo(rowsRef.current);
|
||||
if (prev) setRows(prev);
|
||||
} else if ((key === "z" && e.shiftKey) || key === "y") {
|
||||
e.preventDefault();
|
||||
const next = historyRedo(rowsRef.current);
|
||||
if (next) setRows(next);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [tab, historyUndo, historyRedo]);
|
||||
|
||||
async function onUpload(e, method = "POST") {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -374,7 +498,9 @@ export default function ProjectPage() {
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.generateTable(projectId, tableType);
|
||||
historyPush(rows);
|
||||
setRows(data.rows || []);
|
||||
setPreviewPage(1);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -395,9 +521,55 @@ export default function ProjectPage() {
|
||||
}
|
||||
|
||||
function updateCell(idx, field, value) {
|
||||
historyPush(rows, { mergeKey: `${idx}:${field}` });
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)));
|
||||
}
|
||||
|
||||
function doUndo() {
|
||||
const prev = historyUndo(rows);
|
||||
if (prev) setRows(prev);
|
||||
}
|
||||
|
||||
function doRedo() {
|
||||
const next = historyRedo(rows);
|
||||
if (next) setRows(next);
|
||||
}
|
||||
|
||||
async function protectPages() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.protectTablePages(projectId, tableType, rows);
|
||||
historyPush(rows);
|
||||
setRows(data.rows || []);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startColResize(field, e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startW = colWidths[field] || 80;
|
||||
function onMove(ev) {
|
||||
const next = Math.max(48, startW + (ev.clientX - startX));
|
||||
setColWidths((prev) => {
|
||||
const updated = { ...prev, [field]: next };
|
||||
saveColWidths(tableType, updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
async function saveInscriptions() {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -521,6 +693,7 @@ export default function ProjectPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.llmApply(projectId, tableType, pendingEdits);
|
||||
historyPush(rows);
|
||||
setRows(data.rows || []);
|
||||
setPendingEdits(null);
|
||||
} catch (e) {
|
||||
@@ -932,263 +1105,339 @@ export default function ProjectPage() {
|
||||
)}
|
||||
|
||||
{tab === "tables" && (
|
||||
<div className="grid two">
|
||||
<div className="card stack">
|
||||
<div className="tabs">
|
||||
{TABLE_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`tab ${tableType === t.id ? "active" : ""}`}
|
||||
onClick={() => setTableType(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
<SplitPane
|
||||
storageKey="gost_tables_split"
|
||||
left={
|
||||
<div className="card stack tables-pane">
|
||||
<div className="tabs">
|
||||
{TABLE_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`tab ${tableType === t.id ? "active" : ""}`}
|
||||
onClick={() => setTableType(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button disabled={busy} onClick={generate}>
|
||||
Сгенерировать
|
||||
</button>
|
||||
<button disabled={busy} className="secondary" onClick={saveRows}>
|
||||
Сохранить строки
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button disabled={busy} onClick={generate}>
|
||||
Сгенерировать
|
||||
</button>
|
||||
<button disabled={busy} className="secondary" onClick={saveRows}>
|
||||
Сохранить строки
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "xlsx")}>
|
||||
Excel
|
||||
</button>
|
||||
{tableType !== "simple_list" && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
title="Отменить (Ctrl+Z)"
|
||||
disabled={busy || !canUndo}
|
||||
onClick={doUndo}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
title="Повторить (Ctrl+Y)"
|
||||
disabled={busy || !canRedo}
|
||||
onClick={doRedo}
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
{tableType !== "simple_list" && (
|
||||
<button disabled={busy} className="secondary" onClick={protectPages}>
|
||||
Разбить по страницам
|
||||
</button>
|
||||
)}
|
||||
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "xlsx")}>
|
||||
Excel
|
||||
</button>
|
||||
{tableType !== "simple_list" && (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.exportDoc(projectId, tableType, "pdf");
|
||||
await loadTable();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{overflowCount > 0 && (
|
||||
<div className="overflow-banner">
|
||||
Переполнение ячеек: {overflowCount}. Красным отмечены поля, не влезающие в PDF даже с
|
||||
поджимом. Сократите текст или уменьшите поджим в настройках.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tableSettings?.fields?.length > 0 && (
|
||||
<div className="settings-panel stack">
|
||||
<strong>Настройки таблицы</strong>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Сопоставление колонок со свойствами компонентов и параметрами проекта Altium.
|
||||
</p>
|
||||
<div
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill,minmax(240px,1fr))" }}
|
||||
>
|
||||
{tableSettings.fields.map((field) => (
|
||||
<label key={field.key} className="stack">
|
||||
<span className="muted">{field.label}</span>
|
||||
{field.source === "project_param" ? (
|
||||
<ParamCombo
|
||||
value={tableSettings.column_mappings[field.key] || ""}
|
||||
params={tableSettings.project_params || params}
|
||||
disabled={busy}
|
||||
onChange={(val) => updateColumnMapping(field.key, val)}
|
||||
/>
|
||||
) : (
|
||||
<PropertySelect
|
||||
value={tableSettings.column_mappings[field.key] || ""}
|
||||
properties={tableSettings.property_names || []}
|
||||
disabled={busy}
|
||||
onChange={(val) => updateColumnMapping(field.key, val)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{tableType === "simple_list" && tableSettings.simple_list && (
|
||||
<>
|
||||
<label className="stack">
|
||||
<span className="muted">Тех. запас, %</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={tableSettings.simple_list.tech_reserve_percent ?? 10}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
updateSimpleListSetting("tech_reserve_percent", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="stack">
|
||||
<span className="muted">Кол-во плат</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tableSettings.simple_list.boards_count ?? 1}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
updateSimpleListSetting("boards_count", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button disabled={busy || !settingsDirty} onClick={saveTableSettings}>
|
||||
Сохранить настройки
|
||||
</button>
|
||||
{settingsDirty && <span className="muted">Есть несохранённые изменения</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data resizable">
|
||||
<colgroup>
|
||||
<col style={{ width: 44 }} />
|
||||
{columns.map(([key]) => (
|
||||
<col key={key} style={{ width: colWidths[key] || 80 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{columns.map(([key, label]) => (
|
||||
<th key={key}>
|
||||
<span className="th-label">{label}</span>
|
||||
<span
|
||||
className="col-resize-handle"
|
||||
onMouseDown={(e) => startColResize(key, e)}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
|
||||
<td className="muted">{row.row_index ?? idx}</td>
|
||||
{columns.map(([key]) => (
|
||||
<td
|
||||
key={key}
|
||||
className={row.overflow_fields?.includes(key) ? "cell-overflow" : ""}
|
||||
>
|
||||
<input
|
||||
value={row[key] ?? ""}
|
||||
disabled={row.is_empty}
|
||||
onChange={(e) => updateCell(idx, key, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{tableType !== "simple_list" && (
|
||||
<div className="pdf-preview stack">
|
||||
<div className="row pdf-preview-toolbar">
|
||||
<strong>Превью PDF</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={previewPage <= 1 || previewLoading}
|
||||
onClick={() => setPreviewPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="muted">
|
||||
{previewPage} / {previewTotal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={previewPage >= previewTotal || previewLoading}
|
||||
onClick={() => setPreviewPage((p) => Math.min(previewTotal, p + 1))}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<div className={`pdf-preview-frame${previewLoading ? " loading" : ""}`}>
|
||||
{previewError ? (
|
||||
<div className="muted">{previewError}</div>
|
||||
) : previewUrl ? (
|
||||
<embed src={previewUrl} type="application/pdf" className="pdf-preview-embed" />
|
||||
) : (
|
||||
<div className="muted">Нет данных для превью</div>
|
||||
)}
|
||||
{previewLoading && <div className="pdf-preview-overlay">Обновление…</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="card stack">
|
||||
<h3>LLM-помощник</h3>
|
||||
<p className="muted">
|
||||
Таблица в контексте: <strong>{tableLabel}</strong>. Команды вроде «добавь примечание к
|
||||
R1». История чата учитывается. Правки — только после подтверждения.
|
||||
</p>
|
||||
<div className="row chat-toolbar">
|
||||
<select
|
||||
style={{ flex: 1 }}
|
||||
value={activeChatId ?? ""}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
onChange={async (e) => {
|
||||
const id = e.target.value ? Number(e.target.value) : null;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.exportDoc(projectId, tableType, "pdf");
|
||||
await loadTable();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
await openChat(id);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
{!chats.length && <option value="">Нет чатов</option>}
|
||||
{chats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title} ({c.message_count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="secondary" disabled={busy} onClick={createNewChat}>
|
||||
Новый чат
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy || !activeChatId}
|
||||
onClick={deleteActiveChat}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{overflowCount > 0 && (
|
||||
<div className="overflow-banner">
|
||||
Переполнение ячеек: {overflowCount}. Красным отмечены поля, не влезающие в PDF даже с поджимом.
|
||||
Сократите текст или уменьшите поджим в настройках.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tableSettings?.fields?.length > 0 && (
|
||||
<div className="settings-panel stack">
|
||||
<strong>Настройки таблицы</strong>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Сопоставление колонок со свойствами компонентов и параметрами проекта Altium.
|
||||
</p>
|
||||
<div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill,minmax(240px,1fr))" }}>
|
||||
{tableSettings.fields.map((field) => (
|
||||
<label key={field.key} className="stack">
|
||||
<span className="muted">{field.label}</span>
|
||||
{field.source === "project_param" ? (
|
||||
<ParamCombo
|
||||
value={tableSettings.column_mappings[field.key] || ""}
|
||||
params={tableSettings.project_params || params}
|
||||
disabled={busy}
|
||||
onChange={(val) => updateColumnMapping(field.key, val)}
|
||||
/>
|
||||
) : field.source === "component_field" ? (
|
||||
<PropertySelect
|
||||
value={tableSettings.column_mappings[field.key] || ""}
|
||||
properties={tableSettings.property_names || []}
|
||||
disabled={busy}
|
||||
onChange={(val) => updateColumnMapping(field.key, val)}
|
||||
/>
|
||||
) : (
|
||||
<PropertySelect
|
||||
value={tableSettings.column_mappings[field.key] || ""}
|
||||
properties={tableSettings.property_names || []}
|
||||
disabled={busy}
|
||||
onChange={(val) => updateColumnMapping(field.key, val)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{tableType === "simple_list" && tableSettings.simple_list && (
|
||||
<>
|
||||
<label className="stack">
|
||||
<span className="muted">Тех. запас, %</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={tableSettings.simple_list.tech_reserve_percent ?? 10}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
updateSimpleListSetting("tech_reserve_percent", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="stack">
|
||||
<span className="muted">Кол-во плат</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tableSettings.simple_list.boards_count ?? 1}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
updateSimpleListSetting("boards_count", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="context-meter" title="Оценка заполнения контекста модели">
|
||||
<div className="context-meter-label">
|
||||
<span>Контекст</span>
|
||||
<span>
|
||||
{chatContext
|
||||
? `${chatContext.used.toLocaleString("ru-RU")} / ${chatContext.limit.toLocaleString("ru-RU")} · ${chatContext.percent}%`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button disabled={busy || !settingsDirty} onClick={saveTableSettings}>
|
||||
Сохранить настройки
|
||||
</button>
|
||||
{settingsDirty && <span className="muted">Есть несохранённые изменения</span>}
|
||||
<div className="context-meter-track">
|
||||
<div
|
||||
className={`context-meter-fill${
|
||||
(chatContext?.percent || 0) >= 85
|
||||
? " high"
|
||||
: (chatContext?.percent || 0) >= 60
|
||||
? " mid"
|
||||
: ""
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, chatContext?.percent || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{columns.map(([key, label]) => (
|
||||
<th key={key}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
|
||||
<td className="muted">{row.row_index ?? idx}</td>
|
||||
{columns.map(([key]) => (
|
||||
<td
|
||||
key={key}
|
||||
className={row.overflow_fields?.includes(key) ? "cell-overflow" : ""}
|
||||
>
|
||||
<input
|
||||
value={row[key] ?? ""}
|
||||
disabled={row.is_empty}
|
||||
onChange={(e) => updateCell(idx, key, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<h3>LLM-помощник</h3>
|
||||
<p className="muted">
|
||||
Таблица в контексте: <strong>{tableLabel}</strong>. Команды вроде «добавь примечание к
|
||||
R1». История чата учитывается. Правки — только после подтверждения.
|
||||
</p>
|
||||
<div className="row chat-toolbar">
|
||||
<select
|
||||
style={{ flex: 1 }}
|
||||
value={activeChatId ?? ""}
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
const id = e.target.value ? Number(e.target.value) : null;
|
||||
setBusy(true);
|
||||
try {
|
||||
await openChat(id);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!chats.length && <option value="">Нет чатов</option>}
|
||||
{chats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title} ({c.message_count})
|
||||
</option>
|
||||
<div className="chat">
|
||||
{chat.map((m, i) => (
|
||||
<div key={i} className={`bubble ${m.role}`}>
|
||||
{m.content}
|
||||
{m.edits?.length ? (
|
||||
<div className="edits" style={{ marginTop: 6 }}>
|
||||
Предложено правок: {m.edits.length}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</select>
|
||||
<button className="secondary" disabled={busy} onClick={createNewChat}>
|
||||
Новый чат
|
||||
</button>
|
||||
<button className="secondary" disabled={busy || !activeChatId} onClick={deleteActiveChat}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<div className="context-meter" title="Оценка заполнения контекста модели">
|
||||
<div className="context-meter-label">
|
||||
<span>Контекст</span>
|
||||
<span>
|
||||
{chatContext
|
||||
? `${chatContext.used.toLocaleString("ru-RU")} / ${chatContext.limit.toLocaleString("ru-RU")} · ${chatContext.percent}%`
|
||||
: "—"}
|
||||
</span>
|
||||
{!chat.length && <div className="muted">Начните новый диалог или выберите чат.</div>}
|
||||
</div>
|
||||
<div className="context-meter-track">
|
||||
<div
|
||||
className={`context-meter-fill${
|
||||
(chatContext?.percent || 0) >= 85
|
||||
? " high"
|
||||
: (chatContext?.percent || 0) >= 60
|
||||
? " mid"
|
||||
: ""
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, chatContext?.percent || 0)}%` }}
|
||||
{pendingEdits?.length ? (
|
||||
<div className="edits stack">
|
||||
<strong>Ожидают применения: {pendingEdits.length}</strong>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(pendingEdits, null, 2)}
|
||||
</pre>
|
||||
<div className="row">
|
||||
<button disabled={busy} onClick={applyEdits}>
|
||||
Применить
|
||||
</button>
|
||||
<button className="secondary" onClick={() => setPendingEdits(null)}>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="row">
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
value={chatInput}
|
||||
placeholder="Сообщение…"
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendChat()}
|
||||
/>
|
||||
<button disabled={busy} onClick={sendChat}>
|
||||
Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat">
|
||||
{chat.map((m, i) => (
|
||||
<div key={i} className={`bubble ${m.role}`}>
|
||||
{m.content}
|
||||
{m.edits?.length ? (
|
||||
<div className="edits" style={{ marginTop: 6 }}>
|
||||
Предложено правок: {m.edits.length}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!chat.length && <div className="muted">Начните новый диалог или выберите чат.</div>}
|
||||
</div>
|
||||
{pendingEdits?.length ? (
|
||||
<div className="edits stack">
|
||||
<strong>Ожидают применения: {pendingEdits.length}</strong>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(pendingEdits, null, 2)}
|
||||
</pre>
|
||||
<div className="row">
|
||||
<button disabled={busy} onClick={applyEdits}>
|
||||
Применить
|
||||
</button>
|
||||
<button className="secondary" onClick={() => setPendingEdits(null)}>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="row">
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
value={chatInput}
|
||||
placeholder="Сообщение…"
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendChat()}
|
||||
/>
|
||||
<button disabled={busy} onClick={sendChat}>
|
||||
Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,20 +55,91 @@ input, select, textarea {
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.grid.two { grid-template-columns: 1.4fr 1fr; }
|
||||
.muted { color: var(--muted); }
|
||||
.table-wrap { overflow: auto; max-height: 60vh; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.table-wrap { overflow: auto; max-height: 45vh; border: 1px solid var(--line); border-radius: 8px; }
|
||||
table.data {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.85rem; background: white;
|
||||
}
|
||||
table.data.resizable { table-layout: fixed; }
|
||||
table.data th, table.data td {
|
||||
border-bottom: 1px solid #ebe4d6; padding: 0.25rem 0.35rem; vertical-align: top;
|
||||
}
|
||||
table.data th { position: sticky; top: 0; background: #efe8da; z-index: 1; }
|
||||
table.data th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #efe8da;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
table.data th .th-label { display: block; padding-right: 8px; }
|
||||
table.data .col-resize-handle {
|
||||
position: absolute; right: 0; top: 0; width: 8px; height: 100%;
|
||||
cursor: col-resize; user-select: none; z-index: 2;
|
||||
}
|
||||
table.data input {
|
||||
width: 100%; border: 1px solid transparent; background: transparent; padding: 0.2rem;
|
||||
}
|
||||
table.data input:focus { border-color: var(--accent); background: #fff; }
|
||||
table.data tr.header-row td { font-weight: 700; text-decoration: underline; }
|
||||
table.data td.cell-overflow input { background: #ffd5d5; }
|
||||
|
||||
.split-pane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
min-height: 70vh;
|
||||
}
|
||||
.split-pane-left,
|
||||
.split-pane-right {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.split-pane-left > .card,
|
||||
.split-pane-right > .card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.split-pane-right {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.split-pane-handle {
|
||||
flex: 0 0 8px;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: #d9d0bf;
|
||||
cursor: col-resize;
|
||||
align-self: stretch;
|
||||
}
|
||||
.split-pane-handle:hover { background: var(--accent); }
|
||||
|
||||
.tables-pane { min-height: 0; }
|
||||
.pdf-preview { margin-top: 0.5rem; }
|
||||
.pdf-preview-toolbar { align-items: center; gap: 0.5rem; }
|
||||
.pdf-preview-frame {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f7f3ea;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pdf-preview-frame.loading { opacity: 0.85; }
|
||||
.pdf-preview-embed {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.pdf-preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(247, 243, 234, 0.55);
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.overflow-banner {
|
||||
background: #fff0f0;
|
||||
border: 1px solid #e8a0a0;
|
||||
@@ -186,4 +257,7 @@ textarea {
|
||||
.error { color: var(--danger); }
|
||||
@media (max-width: 960px) {
|
||||
.grid.two { grid-template-columns: 1fr; }
|
||||
.split-pane { flex-direction: column; }
|
||||
.split-pane-left { flex: 1 1 auto !important; width: 100%; }
|
||||
.split-pane-handle { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user