fixed proxy

This commit is contained in:
2026-09-08 13:47:07 +03:00
parent 181f84d372
commit d7bb365c9c
12 changed files with 1000 additions and 243 deletions
+51 -1
View File
@@ -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)
+1
View File
@@ -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)
+10
View File
@@ -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
+119
View File
@@ -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
+41
View File
@@ -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)
+1 -1
View File
@@ -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)
+32
View File
@@ -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",
+80
View File
@@ -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,
};
}
+488 -239
View File
@@ -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>
);
+76 -2
View File
@@ -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; }
}