added web service
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GostGenerator Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
client_max_body_size 512m;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /docs {
|
||||
proxy_pass http://api:8000/docs;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://api:8000/openapi.json;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "gostgenerator-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, Route, Routes } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getToken, setToken } from "./api/client";
|
||||
import ProjectsPage from "./pages/ProjectsPage";
|
||||
import ProjectPage from "./pages/ProjectPage";
|
||||
|
||||
export default function App() {
|
||||
const [token, setTokenState] = useState(getToken());
|
||||
|
||||
useEffect(() => {
|
||||
setToken(token);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<header className="topbar">
|
||||
<Link to="/" className="brand">
|
||||
GostGenerator Web
|
||||
</Link>
|
||||
<div className="row">
|
||||
<label className="muted">API token</label>
|
||||
<input
|
||||
style={{ minWidth: 180 }}
|
||||
value={token}
|
||||
onChange={(e) => setTokenState(e.target.value)}
|
||||
placeholder="Bearer token"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:id" element={<ProjectPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const TOKEN_KEY = "gost_api_token";
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token || "");
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (options.json !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
||||
});
|
||||
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));
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("application/json")) return res.json();
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProjects: () => request("/api/projects"),
|
||||
createProject: (name) => request("/api/projects", { method: "POST", json: { name } }),
|
||||
getProject: (id) => request(`/api/projects/${id}`),
|
||||
updateProject: (id, data) => request(`/api/projects/${id}`, { method: "PATCH", json: data }),
|
||||
deleteProject: (id) => request(`/api/projects/${id}`, { method: "DELETE" }),
|
||||
uploadZip: async (id, file, method = "POST", keepTables = true) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const q = method === "PUT" ? `?keep_tables=${keepTables}` : "";
|
||||
return request(`/api/projects/${id}/upload${q}`, { method, body: fd });
|
||||
},
|
||||
getComponents: (id) => request(`/api/projects/${id}/components`),
|
||||
getParams: (id) => request(`/api/projects/${id}/params`),
|
||||
getPcb: (id) => request(`/api/projects/${id}/pcb`),
|
||||
getInscriptions: (id) => request(`/api/projects/${id}/inscriptions`),
|
||||
patchInscriptions: (id, inscriptions) =>
|
||||
request(`/api/projects/${id}/inscriptions`, { method: "PATCH", json: { inscriptions } }),
|
||||
getTable: (id, type) => request(`/api/projects/${id}/tables/${type}`),
|
||||
patchTable: (id, type, rows) =>
|
||||
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 }),
|
||||
exportDoc: async (id, table_type, format) => {
|
||||
const blob = await request(`/api/projects/${id}/export`, {
|
||||
method: "POST",
|
||||
json: { table_type, format },
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project_${id}_${table_type}.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
llmChat: (id, message, table_type) =>
|
||||
request(`/api/projects/${id}/llm/chat`, { method: "POST", json: { message, table_type } }),
|
||||
llmApply: (id, table_type, edits) =>
|
||||
request(`/api/projects/${id}/llm/apply`, { method: "POST", json: { table_type, edits } }),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const TABLE_TYPES = [
|
||||
{ id: "perechen", label: "Перечень" },
|
||||
{ id: "specification_pcb", label: "Спец. ПП" },
|
||||
{ id: "specification", label: "Спецификация" },
|
||||
{ id: "vedomost", label: "Ведомость" },
|
||||
{ id: "simple_list", label: "Бланк заказа" },
|
||||
];
|
||||
|
||||
const COLUMNS = {
|
||||
perechen: [
|
||||
["position", "Поз."],
|
||||
["designation", "Наименование"],
|
||||
["quantity", "Кол."],
|
||||
["note", "Прим."],
|
||||
],
|
||||
specification_pcb: [
|
||||
["format", "Форм."],
|
||||
["zone", "Зона"],
|
||||
["position", "Поз."],
|
||||
["designation", "Обозн."],
|
||||
["name", "Наименование"],
|
||||
["quantity", "Кол."],
|
||||
["note", "Прим."],
|
||||
],
|
||||
specification: [
|
||||
["format", "Форм."],
|
||||
["zone", "Зона"],
|
||||
["position", "Поз."],
|
||||
["designation", "Обозн."],
|
||||
["name", "Наименование"],
|
||||
["quantity", "Кол."],
|
||||
["note", "Прим."],
|
||||
],
|
||||
vedomost: [
|
||||
["name", "Наименование"],
|
||||
["product_code", "Код"],
|
||||
["document_code", "Док."],
|
||||
["supplier", "Поставщик"],
|
||||
["where_used", "Куда"],
|
||||
["quantity_per_item", "На изд."],
|
||||
["total_quantity", "Всего"],
|
||||
["note", "Прим."],
|
||||
],
|
||||
simple_list: [
|
||||
["designator", "Поз."],
|
||||
["name", "Наименование"],
|
||||
["quantity", "Кол."],
|
||||
],
|
||||
};
|
||||
|
||||
const FRAME_FIELDS = [
|
||||
[1, "Наименование изделия"],
|
||||
[2, "Обозначение документа"],
|
||||
[4, "Литера"],
|
||||
[9, "Организация"],
|
||||
[10, "Характер работы"],
|
||||
[11, "Фамилии"],
|
||||
[111, "Разработал"],
|
||||
[112, "Проверил"],
|
||||
[113, "Н. контроль"],
|
||||
[114, "Утвердил"],
|
||||
[251, "Перв. примен. (перечень)"],
|
||||
[252, "Перв. примен. (спец. ПП)"],
|
||||
[253, "Перв. примен. (спец.)"],
|
||||
[254, "Перв. примен. (ведомость)"],
|
||||
[301, "Наименование документа (ведомость)"],
|
||||
[1001, "Децимальный № (спец.)"],
|
||||
[1002, "Название документа (спец.)"],
|
||||
];
|
||||
|
||||
export default function ProjectPage() {
|
||||
const { id } = useParams();
|
||||
const projectId = Number(id);
|
||||
const [project, setProject] = useState(null);
|
||||
const [params, setParams] = useState([]);
|
||||
const [pcb, setPcb] = useState({ layer_count: 0, materials: [] });
|
||||
const [tableType, setTableType] = useState("perechen");
|
||||
const [rows, setRows] = useState([]);
|
||||
const [inscriptions, setInscriptions] = useState({});
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const [chat, setChat] = useState([]);
|
||||
const [pendingEdits, setPendingEdits] = useState(null);
|
||||
const [tab, setTab] = useState("tables");
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
const [p, par, pcbInfo, insc] = await Promise.all([
|
||||
api.getProject(projectId),
|
||||
api.getParams(projectId),
|
||||
api.getPcb(projectId),
|
||||
api.getInscriptions(projectId),
|
||||
]);
|
||||
setProject(p);
|
||||
setParams(par);
|
||||
setPcb(pcbInfo);
|
||||
setInscriptions(insc || {});
|
||||
}, [projectId]);
|
||||
|
||||
const loadTable = useCallback(async () => {
|
||||
const data = await api.getTable(projectId, tableType);
|
||||
setRows(data.rows || []);
|
||||
}, [projectId, tableType]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setError("");
|
||||
await loadProject();
|
||||
await loadTable();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
})();
|
||||
}, [loadProject, loadTable]);
|
||||
|
||||
const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]);
|
||||
|
||||
async function onUpload(e, method = "POST") {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.uploadZip(projectId, file, method, true);
|
||||
await loadProject();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMeta(patch) {
|
||||
setProject(await api.updateProject(projectId, patch));
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.generateTable(projectId, tableType);
|
||||
setRows(data.rows || []);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRows() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.patchTable(projectId, tableType, rows);
|
||||
setRows(data.rows || []);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCell(idx, field, value) {
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)));
|
||||
}
|
||||
|
||||
async function saveInscriptions() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.patchInscriptions(projectId, inscriptions);
|
||||
setInscriptions(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
if (!chatInput.trim()) return;
|
||||
const message = chatInput.trim();
|
||||
setChatInput("");
|
||||
setChat((c) => [...c, { role: "user", content: message }]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.llmChat(projectId, message, tableType);
|
||||
setChat((c) => [...c, { role: "assistant", content: res.reply, edits: res.edits }]);
|
||||
setPendingEdits(res.edits?.length ? res.edits : null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyEdits() {
|
||||
if (!pendingEdits?.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.llmApply(projectId, tableType, pendingEdits);
|
||||
setRows(data.rows || []);
|
||||
setPendingEdits(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return <div className="card">{error || "Загрузка…"}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="card stack">
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<Link to="/">← К списку</Link>
|
||||
<h2 style={{ margin: "0.4rem 0 0" }}>{project.name}</h2>
|
||||
<div className="muted">
|
||||
Статус: <span className={`status ${project.status}`}>{project.status}</span>
|
||||
{project.error_message ? ` — ${project.error_message}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
|
||||
Загрузить ZIP
|
||||
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "POST")} />
|
||||
</label>
|
||||
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
|
||||
Обновить ZIP
|
||||
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "PUT")} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label>Вариант</label>
|
||||
<select
|
||||
value={project.current_variant}
|
||||
onChange={(e) => saveMeta({ current_variant: e.target.value })}
|
||||
>
|
||||
{(project.variants?.length ? project.variants : ["No Variations"]).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label>Децимальный №</label>
|
||||
<input
|
||||
value={project.decimal_number || ""}
|
||||
onChange={(e) => setProject({ ...project, decimal_number: e.target.value })}
|
||||
onBlur={(e) => saveMeta({ decimal_number: e.target.value })}
|
||||
/>
|
||||
<label>Название платы</label>
|
||||
<input
|
||||
value={project.board_name || ""}
|
||||
onChange={(e) => setProject({ ...project, board_name: e.target.value })}
|
||||
onBlur={(e) => saveMeta({ board_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="muted">
|
||||
Компонентов: {project.component_count}, слоёв PCB: {project.layer_count}
|
||||
{project.pcb_doc_name ? `, файл: ${project.pcb_doc_name}` : ""}
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={`tab ${tab === "tables" ? "active" : ""}`} onClick={() => setTab("tables")}>
|
||||
Таблицы
|
||||
</button>
|
||||
<button className={`tab ${tab === "frame" ? "active" : ""}`} onClick={() => setTab("frame")}>
|
||||
Рамка
|
||||
</button>
|
||||
<button className={`tab ${tab === "info" ? "active" : ""}`} onClick={() => setTab("info")}>
|
||||
Инфо проекта
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "info" && (
|
||||
<div className="grid two">
|
||||
<div className="card">
|
||||
<h3>Параметры проекта</h3>
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Значение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>PCB / диэлектрики</h3>
|
||||
<p className="muted">Слоёв: {pcb.layer_count}</p>
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Слой</th>
|
||||
<th>Тип</th>
|
||||
<th>Материал</th>
|
||||
<th>мм</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pcb.materials?.map((m, i) => (
|
||||
<tr key={i}>
|
||||
<td>{m.layer_number}</td>
|
||||
<td>{m.diel_type === 1 ? "ядро" : "препрег"}</td>
|
||||
<td>{m.value}</td>
|
||||
<td>{m.height?.toFixed?.(3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "frame" && (
|
||||
<div className="card stack">
|
||||
<h3>Поля основной надписи</h3>
|
||||
<div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill,minmax(280px,1fr))" }}>
|
||||
{FRAME_FIELDS.map(([num, label]) => (
|
||||
<label key={num} className="stack">
|
||||
<span className="muted">
|
||||
{num}. {label}
|
||||
</span>
|
||||
<input
|
||||
value={inscriptions[num] || inscriptions[String(num)] || ""}
|
||||
onChange={(e) =>
|
||||
setInscriptions((prev) => ({ ...prev, [num]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button disabled={busy} onClick={saveInscriptions}>
|
||||
Сохранить рамку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
</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 disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "pdf")}>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</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}>
|
||||
<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">Команды вроде «добавь примечание к R1». Правки применяются только после подтверждения.</p>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setProjects(await api.listProjects());
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function create() {
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
const p = await api.createProject(name.trim());
|
||||
setName("");
|
||||
window.location.href = `/projects/${p.id}`;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm("Удалить проект?")) return;
|
||||
try {
|
||||
await api.deleteProject(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="card stack">
|
||||
<h2 style={{ margin: 0 }}>Проекты</h2>
|
||||
<div className="row">
|
||||
<input
|
||||
placeholder="Название проекта"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && create()}
|
||||
/>
|
||||
<button onClick={create}>Создать</button>
|
||||
<button className="secondary" onClick={load}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div className="muted">Загрузка…</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="muted">Пока нет проектов</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Статус</th>
|
||||
<th>Компоненты</th>
|
||||
<th>Слои</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td>
|
||||
<Link to={`/projects/${p.id}`}>{p.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status ${p.status}`}>{p.status}</span>
|
||||
</td>
|
||||
<td>{p.component_count}</td>
|
||||
<td>{p.layer_count}</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => remove(p.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
:root {
|
||||
--bg: #f3f0e8;
|
||||
--panel: #fffdf8;
|
||||
--ink: #1c2420;
|
||||
--muted: #5c6a63;
|
||||
--line: #cfc6b4;
|
||||
--accent: #1f6f5b;
|
||||
--accent-2: #c45c26;
|
||||
--danger: #9b2c2c;
|
||||
--ok: #1f6f5b;
|
||||
font-family: "Segoe UI", "IBM Plex Sans", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, #e8efe8 0, transparent 40%),
|
||||
radial-gradient(circle at 90% 0%, #f7e7d6 0, transparent 35%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary { background: #4a5a53; }
|
||||
button.danger { background: var(--danger); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
input, select, textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: white;
|
||||
}
|
||||
.layout { max-width: 1400px; margin: 0 auto; padding: 1.25rem; }
|
||||
.topbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 1rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.brand { font-size: 1.4rem; font-weight: 700; letter-spacing: 0.02em; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(40, 30, 10, 0.04);
|
||||
}
|
||||
.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.data {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.85rem; background: white;
|
||||
}
|
||||
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 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; }
|
||||
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.tab {
|
||||
background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem;
|
||||
}
|
||||
.tab.active { background: var(--accent); color: white; }
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.stack { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.chat {
|
||||
display: flex; flex-direction: column; gap: 0.5rem; max-height: 50vh; overflow: auto;
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem; background: #fff;
|
||||
}
|
||||
.bubble { padding: 0.5rem 0.65rem; border-radius: 8px; white-space: pre-wrap; }
|
||||
.bubble.user { background: #e5f0ec; align-self: flex-end; }
|
||||
.bubble.assistant { background: #f4eee4; align-self: flex-start; }
|
||||
.edits { font-size: 0.8rem; background: #fff7ea; border: 1px dashed var(--accent-2); padding: 0.5rem; border-radius: 6px; }
|
||||
.status {
|
||||
display: inline-block; padding: 0.1rem 0.45rem; border-radius: 999px;
|
||||
background: #e7e0d2; font-size: 0.75rem;
|
||||
}
|
||||
.status.ready { background: #d7efe4; }
|
||||
.status.error { background: #f5d5d5; }
|
||||
.error { color: var(--danger); }
|
||||
@media (max-width: 960px) {
|
||||
.grid.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user