added web service

This commit is contained in:
2026-09-02 12:48:03 +03:00
parent 84b7eceb9f
commit 02c43072c4
60 changed files with 6148 additions and 22 deletions
+11
View File
@@ -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
+12
View File
@@ -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>
+28
View File
@@ -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;
}
}
+22
View File
@@ -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"
}
}
+36
View File
@@ -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>
);
}
+78
View File
@@ -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 } }),
};
+13
View File
@@ -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>
);
+474
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+95
View File
@@ -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; }
}
+12
View File
@@ -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",
},
},
});