Compare commits

...

5 Commits

Author SHA1 Message Date
Artem Tsyrulnikov
ddba2dc3e1 перенес создание протокола в Go Excelize 2026-04-05 14:59:54 +03:00
Artem Tsyrulnikov
730d453371 тестовый го бэкэнд 2026-03-27 00:18:42 +03:00
Artem Tsyrulnikov
9edb9d6aca Update protoc-frontend.exe 2026-03-26 18:09:38 +03:00
Artem Tsyrulnikov
34eb6bea56 Update Page.tsx 2026-03-26 18:08:55 +03:00
Artem Tsyrulnikov
e5ca68423c фикс на функции 2026-03-26 10:44:11 +03:00
14 changed files with 1173 additions and 34 deletions

Binary file not shown.

View File

@@ -1,3 +1,17 @@
module protoc-frontend-server
go 1.25.5
require (
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

22
server/go.sum Normal file
View File

@@ -0,0 +1,22 @@
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

View File

@@ -11,6 +11,8 @@ import (
"net/url"
"os"
"strings"
"github.com/go-chi/chi/v5"
)
//go:embed dist/*
@@ -31,7 +33,6 @@ func main() {
originalDirector := proxy.Director
proxy.Director = func(r *http.Request) {
originalDirector(r)
// Rewrite /api/... -> /...
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
if r.URL.Path == "" {
r.URL.Path = "/"
@@ -46,31 +47,34 @@ func main() {
}
fileServer := http.FileServer(http.FS(distSub))
mux := http.NewServeMux()
r := chi.NewRouter()
// API proxy
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
// API routes
r.Route("/api", func(api chi.Router) {
// Protocol generation handled locally (xlsx fill via excelize)
api.Post("/protocols/", protocolHandler(*apiTarget))
// All other /api/* requests proxied to Python backend
api.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
})
})
// SPA: serve static files, fallback to index.html
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
// Try to serve the file directly
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
// Check if file exists in embedded FS
if f, err := distSub.Open(path); err == nil {
f.Close()
fileServer.ServeHTTP(w, r)
return
}
// Fallback to index.html for SPA routing
indexFile, err := fs.ReadFile(distSub, "index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
@@ -84,7 +88,7 @@ func main() {
log.Printf("Serving on http://localhost%s", addr)
log.Printf("API proxy -> %s", *apiTarget)
if err := http.ListenAndServe(addr, mux); err != nil {
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatal(err)
os.Exit(1)
}

107
server/protocol.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
type CreateProtocolRequest struct {
TemplateID string `json:"template_id"`
FileID string `json:"file_id"`
CellsToUpdate map[string]any `json:"cells_to_update"`
PrintArea string `json:"print_area"`
}
func protocolHandler(backendURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req CreateProtocolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Fetch file binary from Python backend
fileURL := fmt.Sprintf("%s/files/%s/content", backendURL, req.FileID)
resp, err := http.Get(fileURL)
if err != nil {
http.Error(w, "failed to fetch file: "+err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, fmt.Sprintf("backend returned %d when fetching file", resp.StatusCode), http.StatusInternalServerError)
return
}
fileBytes, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "failed to read file content: "+err.Error(), http.StatusInternalServerError)
return
}
// Open xlsx with excelize
f, err := excelize.OpenReader(bytes.NewReader(fileBytes))
if err != nil {
http.Error(w, "failed to open xlsx: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
// Get the first sheet
sheetName := f.GetSheetName(0)
if sheetName == "" {
http.Error(w, "no sheets found in xlsx", http.StatusInternalServerError)
return
}
// Fill cells
for cellAddr, value := range req.CellsToUpdate {
if err := setCellValue(f, sheetName, cellAddr, value); err != nil {
http.Error(w, fmt.Sprintf("failed to set cell %s: %v", cellAddr, err), http.StatusInternalServerError)
return
}
}
// Write result to buffer
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
http.Error(w, "failed to write xlsx: "+err.Error(), http.StatusInternalServerError)
return
}
protocolID := uuid.New().String()
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="protocol_%s.xlsx"`, protocolID))
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
w.Write(buf.Bytes())
}
}
func setCellValue(f *excelize.File, sheet, cell string, value any) error {
switch v := value.(type) {
case float64:
// JSON numbers are float64; check if it's actually an integer
if v == float64(int64(v)) {
return f.SetCellInt(sheet, cell, int64(v))
}
return f.SetCellFloat(sheet, cell, v, -1, 64)
case string:
return f.SetCellStr(sheet, cell, v)
case bool:
return f.SetCellBool(sheet, cell, v)
case nil:
return f.SetCellStr(sheet, cell, "")
default:
return f.SetCellStr(sheet, cell, fmt.Sprintf("%v", v))
}
}

View File

@@ -12,6 +12,7 @@ import {
} from '@/lib/hooks/useToast'
import {
ElementsCreation,
FunctionsWikiPage,
ProtocolCreation,
StandardsManagementPage,
TemplateEditPage,
@@ -59,6 +60,10 @@ const App: FC = () => {
path="standards-management"
element={<StandardsManagementPage />}
/>
<Route
path="functions-wiki"
element={<FunctionsWikiPage />}
/>
</Route>
</Routes>
</TemplateProvider>

View File

@@ -184,6 +184,156 @@ describe('Workbook — формулы', () => {
})
})
// ==================== ЕСЛИ с операторами сравнения ====================
describe('ЕСЛИ с операторами = >= <>', () => {
it('ЕСЛИ(A1=5, ...) — равенство числу (совпадает)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 5)
sheet.setCell('B1', '=ЕСЛИ(A1=5, "Пять", "Другое")')
expect(wb.getValue('Sheet1!B1')).toBe('Пять')
})
it('ЕСЛИ(A1=5, ...) — равенство числу (не совпадает)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 7)
sheet.setCell('B1', '=ЕСЛИ(A1=5, "Пять", "Другое")')
expect(wb.getValue('Sheet1!B1')).toBe('Другое')
})
it('ЕСЛИ(A1=B1, ...) — сравнение двух ячеек (равны)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 10)
sheet.setCell('B1', 10)
sheet.setCell('C1', '=ЕСЛИ(A1=B1, "Равны", "Не равны")')
expect(wb.getValue('Sheet1!C1')).toBe('Равны')
})
it('ЕСЛИ(A1=B1, ...) — сравнение двух ячеек (не равны)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 10)
sheet.setCell('B1', 20)
sheet.setCell('C1', '=ЕСЛИ(A1=B1, "Равны", "Не равны")')
expect(wb.getValue('Sheet1!C1')).toBe('Не равны')
})
it('ЕСЛИ(A1>=100, ...) — больше или равно (истина)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 100)
sheet.setCell('B1', '=ЕСЛИ(A1>=100, "Сотня+", A1)')
expect(wb.getValue('Sheet1!B1')).toBe('Сотня+')
})
it('ЕСЛИ(A1>=100, ...) — больше или равно (ложь)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 42)
sheet.setCell('B1', '=ЕСЛИ(A1>=100, "Сотня+", A1)')
expect(wb.getValue('Sheet1!B1')).toBe(42)
})
it('ЕСЛИ(A1>10, ...) — строгое больше', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 15)
sheet.setCell('B1', '=ЕСЛИ(A1>10, "Да", "Нет")')
expect(wb.getValue('Sheet1!B1')).toBe('Да')
})
it('ЕСЛИ с И и операторами >= <=', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 5)
sheet.setCell('B1', '=ЕСЛИ(И(A1>=0, A1<=10), "В диапазоне", "Вне")')
expect(wb.getValue('Sheet1!B1')).toBe('В диапазоне')
})
it('ЕСЛИ с И и операторами >= <= — вне диапазона', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 15)
sheet.setCell('B1', '=ЕСЛИ(И(A1>=0, A1<=10), "В диапазоне", "Вне")')
expect(wb.getValue('Sheet1!B1')).toBe('Вне')
})
it('ЕСЛИ(A1>10, ...) — строгое больше (ложь)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 5)
sheet.setCell('B1', '=ЕСЛИ(A1>10, "Да", "Нет")')
expect(wb.getValue('Sheet1!B1')).toBe('Нет')
})
it('ЕСЛИ с БОЛЬШЕ — истина', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('B1', '=ЕСЛИ(БОЛЬШЕ(5, 3), 1, 0)')
expect(wb.getValue('Sheet1!B1')).toBe(1)
})
it('ЕСЛИ с БОЛЬШЕ — ложь', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('B1', '=ЕСЛИ(БОЛЬШЕ(1, 3), 1, 0)')
expect(wb.getValue('Sheet1!B1')).toBe(0)
})
it('ЕСЛИ с равенством текста в ячейке', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 'Готово')
sheet.setCell('B1', '=ЕСЛИ(A1="Готово", "✓", "✗")')
expect(wb.getValue('Sheet1!B1')).toBe('✓')
})
it('ЕСЛИ с неравенством текста в ячейке', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 'В работе')
sheet.setCell('B1', '=ЕСЛИ(A1="Готово", "✓", "✗")')
expect(wb.getValue('Sheet1!B1')).toBe('✗')
})
})
describe('Оператор <> (не равно)', () => {
it('ЕСЛИ(A1<>0, ...) — не равно (истина)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 5)
sheet.setCell('B1', '=ЕСЛИ(A1<>0, "Не ноль", "Ноль")')
expect(wb.getValue('Sheet1!B1')).toBe('Не ноль')
})
it('ЕСЛИ(A1<>0, ...) — не равно (ложь)', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 0)
sheet.setCell('B1', '=ЕСЛИ(A1<>0, "Не ноль", "Ноль")')
expect(wb.getValue('Sheet1!B1')).toBe('Ноль')
})
it('ЕСЛИ(A1<>B1, ...) — две ячейки не равны', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 10)
sheet.setCell('B1', 20)
sheet.setCell('C1', '=ЕСЛИ(A1<>B1, "Разные", "Одинаковые")')
expect(wb.getValue('Sheet1!C1')).toBe('Разные')
})
it('ЕСЛИ(A1<>B1, ...) — две ячейки равны', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 10)
sheet.setCell('B1', 10)
sheet.setCell('C1', '=ЕСЛИ(A1<>B1, "Разные", "Одинаковые")')
expect(wb.getValue('Sheet1!C1')).toBe('Одинаковые')
})
it('<> внутри строкового литерала не трогается', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 1)
sheet.setCell('B1', '=ЕСЛИ(A1=1, "a<>b", "нет")')
expect(wb.getValue('Sheet1!B1')).toBe('a<>b')
})
})
describe('Операторы внутри строковых литералов не трогаются', () => {
it('= внутри строкового литерала остаётся как есть', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 1)
sheet.setCell('B1', '=ЕСЛИ(A1=1, "x=y", "нет")')
expect(wb.getValue('Sheet1!B1')).toBe('x=y')
})
})
// ==================== ЕПУСТО ====================
describe('ЕПУСТО в формуле', () => {
@@ -679,3 +829,130 @@ describe('CellUtils', () => {
expect(CellUtils.createCellName('B', 3)).toBe('B3')
})
})
// ==================== Edge Cases ====================
describe('Edge Cases функций через движок', () => {
// 1. ОКРУГЛ с отрицательными разрядами: ОКРУГЛ(1234, -2) = 1200 в Excel
describe('ОКРУГЛ с отрицательными разрядами', () => {
it('ОКРУГЛ(1234, -2) должно дать 1200', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=ОКРУГЛ(1234, -2)')
expect(wb.getValue('Sheet1!A1')).toBe(1200)
})
it('ОКРУГЛ(1550, -3) должно дать 2000', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=ОКРУГЛ(1550, -3)')
expect(wb.getValue('Sheet1!A1')).toBe(2000)
})
})
// 2. ЕСЛИ с числом как условие (0 = ложь, не-0 = истина)
describe('ЕСЛИ с числовым условием', () => {
it('0 как ложь', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 0)
sheet.setCell('B1', '=ЕСЛИ(A1, "да", "нет")')
expect(wb.getValue('Sheet1!B1')).toBe('нет')
})
it('ненулевое число как истина', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 42)
sheet.setCell('B1', '=ЕСЛИ(A1, "да", "нет")')
expect(wb.getValue('Sheet1!B1')).toBe('да')
})
})
// 3. СЦЕПИТЬ с числами
describe('СЦЕПИТЬ с числами', () => {
it('число конвертируется в строку', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 42)
sheet.setCell('B1', '=СЦЕПИТЬ("итого: ", A1)')
expect(wb.getValue('Sheet1!B1')).toBe('итого: 42')
})
})
// 4. Несколько = в одной формуле
describe('Несколько = в одной формуле', () => {
it('И(A1=1, B1=2) — оба true', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 1)
sheet.setCell('B1', 2)
sheet.setCell('C1', '=ЕСЛИ(И(A1=1, B1=2), "да", "нет")')
expect(wb.getValue('Sheet1!C1')).toBe('да')
})
it('И(A1=1, B1=2) — одно false', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 1)
sheet.setCell('B1', 99)
sheet.setCell('C1', '=ЕСЛИ(И(A1=1, B1=2), "да", "нет")')
expect(wb.getValue('Sheet1!C1')).toBe('нет')
})
})
// 5. Пустая ячейка в сравнении (движок подставляет 0)
describe('Пустая ячейка в сравнении с =', () => {
it('пустая ячейка = 0', () => {
const { wb, sheet } = createWorkbookWithSheet()
// A1 не установлена — пустая, движок подставит 0
sheet.setCell('B1', '=ЕСЛИ(A1=0, "пусто/ноль", "есть значение")')
expect(wb.getValue('Sheet1!B1')).toBe('пусто/ноль')
})
})
// 6. СТЕПЕНЬ(0, 0) — в Excel = 1
describe('СТЕПЕНЬ edge cases', () => {
it('СТЕПЕНЬ(0, 0) = 1', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=СТЕПЕНЬ(0, 0)')
expect(wb.getValue('Sheet1!A1')).toBe(1)
})
})
// 7. МАКС — вариадический (как в Excel)
describe('МАКС', () => {
// Обратная совместимость: 2 аргумента (как было раньше)
it('МАКС(3, 7) = 7 — обратная совместимость', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=МАКС(3, 7)')
expect(wb.getValue('Sheet1!A1')).toBe(7)
})
it('МАКС(10, 5) = 10 — обратная совместимость', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=МАКС(10, 5)')
expect(wb.getValue('Sheet1!A1')).toBe(10)
})
it('МАКС(-5, 3) = 3 — обратная совместимость с отрицательными', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=МАКС(-5, 3)')
expect(wb.getValue('Sheet1!A1')).toBe(3)
})
it('МАКС со ссылками на ячейки — обратная совместимость', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', 25)
sheet.setCell('A2', 10)
sheet.setCell('B1', '=МАКС(A1, A2)')
expect(wb.getValue('Sheet1!B1')).toBe(25)
})
// Новое: 3+ аргумента
it('МАКС(3, 7, 100) = 100 — вариадический', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=МАКС(3, 7, 100)')
expect(wb.getValue('Sheet1!A1')).toBe(100)
})
it('МАКС(1, 2, 3, 4, 5) = 5', () => {
const { wb, sheet } = createWorkbookWithSheet()
sheet.setCell('A1', '=МАКС(1, 2, 3, 4, 5)')
expect(wb.getValue('Sheet1!A1')).toBe(5)
})
})
})

View File

@@ -131,7 +131,9 @@ export class Cell {
try {
const cell = this.sheet.workbook.getCell(qualifiedRef)
const raw = cell.raw
return (raw === null || raw === undefined || raw === '') ? 'true' : 'false'
return raw === null || raw === undefined || raw === ''
? 'true'
: 'false'
} catch {
return 'true'
}
@@ -230,6 +232,21 @@ export class Cell {
}
}
// Конвертируем Excel-операторы сравнения в JS-операторы
// Сохраняем строковые литералы, чтобы не трогать операторы внутри них
const strings: string[] = []
code = code.replace(/"[^"]*"/g, m => {
strings.push(m)
return `__STR${strings.length - 1}__`
})
// <> → !== (не равно, как в Excel)
code = code.replace(/<>/g, '!==')
// >= и <= уже валидны в JS, пропускаем
// Одиночное = (не ==, !=, >=, <=) → === (равенство вместо присваивания)
code = code.replace(/(?<![<>!=])=(?!=)/g, '===')
// Восстанавливаем строковые литералы
code = code.replace(/__STR(\d+)__/g, (_, i) => strings[Number(i)])
// Создаём контекст для выполнения
const context = {
__functions__: functions,

View File

@@ -41,14 +41,23 @@ describe('DEFAULT_FUNCTIONS', () => {
})
describe('МАКС', () => {
it('возвращает максимум', () => {
it('возвращает максимум из двух', () => {
expect(DEFAULT_FUNCTIONS.МАКС(3, 7)).toBe(7)
})
it('ошибка при не-числах', () => {
expect(DEFAULT_FUNCTIONS.МАКС('a' as any, 1)).toBe('#ЗНАЧ!')
it('возвращает максимум из нескольких', () => {
expect(DEFAULT_FUNCTIONS.МАКС(3, 7, 100)).toBe(100)
})
it('ошибка при Infinity', () => {
expect(DEFAULT_FUNCTIONS.МАКС(Infinity, 1)).toBe('#ЧИСЛО!')
it('работает с массивом', () => {
expect(DEFAULT_FUNCTIONS.МАКС([3, 7, 1])).toBe(7)
})
it('игнорирует не-числа', () => {
expect(DEFAULT_FUNCTIONS.МАКС('a' as any, 1)).toBe(1)
})
it('игнорирует Infinity', () => {
expect(DEFAULT_FUNCTIONS.МАКС(Infinity, 1)).toBe(1)
})
it('ошибка без валидных чисел', () => {
expect(DEFAULT_FUNCTIONS.МАКС('a' as any, 'b' as any)).toBe('#ЗНАЧ!')
})
})

View File

@@ -67,18 +67,11 @@ export const DEFAULT_FUNCTIONS: Record<string, ExcelFunction> = {
const flatArgs = args.flat()
return flatArgs.filter(val => typeof val === 'number').length
},
МАКС: (число1: number, число2: number) => {
// Проверка валидности входных параметров
if (typeof число1 !== 'number' || typeof число2 !== 'number') {
return '#ЗНАЧ!' // Ошибка значения
}
if (!isFinite(число1) || !isFinite(число2)) {
return '#ЧИСЛО!' // Ошибка числа
}
// Возвращаем максимальное из двух чисел
return Math.max(число1, число2)
МАКС: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter(val => typeof val === 'number' && isFinite(val))
if (validArgs.length === 0) return '#ЗНАЧ!'
return Math.max(...validArgs)
},
МИН: (...args: any[]) => {
const flatArgs = args.flat()

View File

@@ -0,0 +1,669 @@
import { ArrowLeft, BookOpen, Calculator, Hash, Search, Sigma, Type } from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
interface FunctionInfo {
name: string
category: string
description: string
syntax: string
args: { name: string; description: string; optional?: boolean }[]
examples: { formula: string; result: string }[]
notes?: string
}
const FUNCTION_CATALOG: FunctionInfo[] = [
// Случайные числа
{
name: 'СЛУЧ',
category: 'Случайные числа',
description: 'Возвращает случайное число от 0 до 1.',
syntax: 'СЛУЧ()',
args: [],
examples: [
{ formula: '=СЛУЧ()', result: '0.7231...' },
],
notes: 'Волатильная функция — пересчитывается при каждом изменении.',
},
{
name: 'СЛУЧМЕЖДУ',
category: 'Случайные числа',
description: 'Возвращает случайное целое число в заданном диапазоне.',
syntax: 'СЛУЧМЕЖДУ(низ,верх)',
args: [
{ name: 'низ', description: 'Нижняя граница диапазона' },
{ name: 'верх', description: 'Верхняя граница диапазона' },
],
examples: [
{ formula: '=СЛУЧМЕЖДУ(1,10)', result: '7' },
{ formula: '=СЛУЧМЕЖДУ(100,200)', result: '142' },
],
notes: 'Волатильная функция.',
},
{
name: 'СЛУЧДР',
category: 'Случайные числа',
description: 'Возвращает случайное дробное число в заданном диапазоне с указанным количеством знаков после запятой.',
syntax: 'СЛУЧДР(низ,верх,знаков)',
args: [
{ name: 'низ', description: 'Нижняя граница диапазона' },
{ name: 'верх', description: 'Верхняя граница диапазона' },
{ name: 'знаков', description: 'Количество знаков после запятой (015)', optional: true },
],
examples: [
{ formula: '=СЛУЧДР(1,10,2)', result: '4.73' },
{ formula: '=СЛУЧДР(0,1,4)', result: '0.5182' },
],
notes: 'Волатильная функция. Если низ > верх, значения меняются местами автоматически.',
},
// Математические
{
name: 'СУММ',
category: 'Математические',
description: 'Суммирует числовые значения. Нечисловые значения игнорируются.',
syntax: 'СУММ(число1,число2,...)',
args: [
{ name: 'число1,число2,...', description: 'Числа или диапазоны ячеек для суммирования' },
],
examples: [
{ formula: '=СУММ(1,2,3)', result: '6' },
{ formula: '=СУММ(A1:A5)', result: 'Сумма ячеек A1A5' },
],
},
{
name: 'СРЗНАЧ',
category: 'Математические',
description: 'Вычисляет среднее арифметическое числовых значений.',
syntax: 'СРЗНАЧ(число1,число2,...)',
args: [
{ name: 'число1,число2,...', description: 'Числа или диапазоны ячеек' },
],
examples: [
{ formula: '=СРЗНАЧ(10,20,30)', result: '20' },
{ formula: '=СРЗНАЧ(A1:A3)', result: 'Среднее значений A1A3' },
],
},
{
name: 'СЧЁТ',
category: 'Математические',
description: 'Подсчитывает количество числовых значений в аргументах.',
syntax: 'СЧЁТ(значение1,значение2,...)',
args: [
{ name: 'значение1,значение2,...', description: 'Значения или диапазоны ячеек' },
],
examples: [
{ formula: '=СЧЁТ(1,"текст",3,5)', result: '3' },
],
},
{
name: 'МАКС',
category: 'Математические',
description: 'Возвращает наибольшее из двух чисел.',
syntax: 'МАКС(число1,число2)',
args: [
{ name: 'число1', description: 'Первое число' },
{ name: 'число2', description: 'Второе число' },
],
examples: [
{ formula: '=МАКС(10,25)', result: '25' },
{ formula: '=МАКС(-5,3)', result: '3' },
],
},
{
name: 'МИН',
category: 'Математические',
description: 'Возвращает наименьшее числовое значение из аргументов.',
syntax: 'МИН(число1,число2,...)',
args: [
{ name: 'число1,число2,...', description: 'Числа или диапазоны ячеек' },
],
examples: [
{ formula: '=МИН(10,5,20)', result: '5' },
{ formula: '=МИН(A1:A3)', result: 'Минимальное из A1A3' },
],
},
{
name: 'ABS',
category: 'Математические',
description: 'Возвращает абсолютное значение (модуль) числа.',
syntax: 'ABS(число)',
args: [
{ name: 'число', description: 'Число, модуль которого нужно найти' },
],
examples: [
{ formula: '=ABS(-15)', result: '15' },
{ formula: '=ABS(7)', result: '7' },
],
},
{
name: 'КОРЕНЬ',
category: 'Математические',
description: 'Возвращает квадратный корень числа.',
syntax: 'КОРЕНЬ(число)',
args: [
{ name: 'число', description: 'Неотрицательное число' },
],
examples: [
{ formula: '=КОРЕНЬ(25)', result: '5' },
{ formula: '=КОРЕНЬ(2)', result: '1.4142...' },
],
notes: 'Возвращает #ЧИСЛО! для отрицательных чисел.',
},
{
name: 'СТЕПЕНЬ',
category: 'Математические',
description: 'Возводит число в указанную степень.',
syntax: 'СТЕПЕНЬ(число,степень)',
args: [
{ name: 'число', description: 'Основание' },
{ name: 'степень', description: 'Показатель степени' },
],
examples: [
{ formula: '=СТЕПЕНЬ(2,10)', result: '1024' },
{ formula: '=СТЕПЕНЬ(5,2)', result: '25' },
],
},
// Округление
{
name: 'ОКРУГЛ',
category: 'Округление',
description: 'Округляет число до указанного количества разрядов.',
syntax: 'ОКРУГЛ(число,разряды)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'разряды', description: 'Количество десятичных разрядов', optional: true },
],
examples: [
{ formula: '=ОКРУГЛ(3.456,2)', result: '3.46' },
{ formula: '=ОКРУГЛ(3.456,0)', result: '3' },
],
},
{
name: 'ОКРУГЛВВЕРХ',
category: 'Округление',
description: 'Округляет число вверх (от нуля) до указанного количества разрядов.',
syntax: 'ОКРУГЛВВЕРХ(число,разряды)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'разряды', description: 'Количество десятичных разрядов', optional: true },
],
examples: [
{ formula: '=ОКРУГЛВВЕРХ(3.21,1)', result: '3.3' },
{ formula: '=ОКРУГЛВВЕРХ(3.1,0)', result: '4' },
],
},
{
name: 'ОКРУГЛВНИЗ',
category: 'Округление',
description: 'Округляет число вниз (к нулю) до указанного количества разрядов.',
syntax: 'ОКРУГЛВНИЗ(число,разряды)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'разряды', description: 'Количество десятичных разрядов', optional: true },
],
examples: [
{ formula: '=ОКРУГЛВНИЗ(3.89,1)', result: '3.8' },
{ formula: '=ОКРУГЛВНИЗ(3.9,0)', result: '3' },
],
},
{
name: 'ОКРВВЕРХ',
category: 'Округление',
description: 'Округляет число вверх до ближайшего кратного.',
syntax: 'ОКРВВЕРХ(число,кратное)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'кратное', description: 'Кратное, до которого округляется', optional: true },
],
examples: [
{ formula: '=ОКРВВЕРХ(7,5)', result: '10' },
{ formula: '=ОКРВВЕРХ(23,10)', result: '30' },
],
},
{
name: 'ОКРВНИЗ',
category: 'Округление',
description: 'Округляет число вниз до ближайшего кратного.',
syntax: 'ОКРВНИЗ(число,кратное)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'кратное', description: 'Кратное, до которого округляется', optional: true },
],
examples: [
{ formula: '=ОКРВНИЗ(7,5)', result: '5' },
{ formula: '=ОКРВНИЗ(27,10)', result: '20' },
],
},
{
name: 'ЦЕЛОЕ',
category: 'Округление',
description: 'Округляет число вниз до ближайшего целого.',
syntax: 'ЦЕЛОЕ(число)',
args: [
{ name: 'число', description: 'Число для округления' },
],
examples: [
{ formula: '=ЦЕЛОЕ(7.8)', result: '7' },
{ formula: '=ЦЕЛОЕ(-3.2)', result: '-4' },
],
},
{
name: 'ОТБР',
category: 'Округление',
description: 'Отбрасывает дробную часть числа до указанного количества разрядов.',
syntax: 'ОТБР(число,разряды)',
args: [
{ name: 'число', description: 'Число для усечения' },
{ name: 'разряды', description: 'Количество десятичных разрядов', optional: true },
],
examples: [
{ formula: '=ОТБР(7.89,1)', result: '7.8' },
{ formula: '=ОТБР(-3.7,0)', result: '-3' },
],
},
{
name: 'ОКРУГЛТ',
category: 'Округление',
description: 'Округляет число до ближайшего кратного.',
syntax: 'ОКРУГЛТ(число,кратное)',
args: [
{ name: 'число', description: 'Число для округления' },
{ name: 'кратное', description: 'Кратное, до которого округляется' },
],
examples: [
{ formula: '=ОКРУГЛТ(7,3)', result: '6' },
{ formula: '=ОКРУГЛТ(10,3)', result: '9' },
],
},
// Логические
{
name: 'ЕСЛИ',
category: 'Логические',
description: 'Возвращает одно значение, если условие истинно, и другое — если ложно.',
syntax: 'ЕСЛИ(условие,истина,ложь)',
args: [
{ name: 'условие', description: 'Логическое выражение для проверки' },
{ name: 'истина', description: 'Значение, если условие истинно' },
{ name: 'ложь', description: 'Значение, если условие ложно' },
],
examples: [
{ formula: '=ЕСЛИ(A1>10, "Да", "Нет")', result: '"Да" если A1 > 10' },
{ formula: '=ЕСЛИ(A1=5, "Пять", "Другое")', result: '"Пять" если A1 равно 5' },
{ formula: '=ЕСЛИ(A1=B1, "Равны", "Не равны")', result: 'Сравнение двух ячеек на равенство' },
{ formula: '=ЕСЛИ(A1>=100, "Сотня+", A1)', result: '"Сотня+" если A1 >= 100, иначе значение A1' },
{ formula: '=ЕСЛИ(БОЛЬШЕ(5, 3), 1, 0)', result: '1' },
{ formula: '=ЕСЛИ(A1="Готово", "✓", "✗")', result: '"✓" если A1 содержит текст "Готово"' },
{ formula: '=ЕСЛИ(A1<>0, "Не ноль", "Ноль")', result: '"Не ноль" если A1 не равно 0' },
{ formula: '=ЕСЛИ(И(A1>=0, A1<=10), "В диапазоне", "Вне")', result: 'Проверка попадания в диапазон 010' },
],
},
{
name: 'И',
category: 'Логические',
description: 'Возвращает ИСТИНА, если все аргументы истинны.',
syntax: 'И(условие1,условие2,...)',
args: [
{ name: 'условие1,условие2,...', description: 'Логические выражения' },
],
examples: [
{ formula: '=И(БОЛЬШЕ(5;3),МЕНЬШЕ(2;4))', result: 'ИСТИНА' },
{ formula: '=ЕСЛИ(И(A1>0,A1<100),"ОК","Ошибка")', result: '"ОК" если 0 < A1 < 100' },
],
},
{
name: 'ИЛИ',
category: 'Логические',
description: 'Возвращает ИСТИНА, если хотя бы один аргумент истинен.',
syntax: 'ИЛИ(условие1,условие2,...)',
args: [
{ name: 'условие1,условие2,...', description: 'Логические выражения' },
],
examples: [
{ formula: '=ИЛИ(БОЛЬШЕ(1;5),БОЛЬШЕ(10;5))', result: 'ИСТИНА' },
],
},
{
name: 'НЕ',
category: 'Логические',
description: 'Инвертирует логическое значение.',
syntax: 'НЕ(значение)',
args: [
{ name: 'значение', description: 'Логическое значение для инверсии' },
],
examples: [
{ formula: '=НЕ(ИСТИНА)', result: 'ЛОЖЬ' },
{ formula: '=НЕ(БОЛЬШЕ(1;5))', result: 'ИСТИНА' },
],
},
// Сравнение
{
name: 'БОЛЬШЕ',
category: 'Сравнение',
description: 'Проверяет, больше ли первое число второго.',
syntax: 'БОЛЬШЕ(число1,число2)',
args: [
{ name: 'число1', description: 'Первое число' },
{ name: 'число2', description: 'Второе число' },
],
examples: [
{ formula: '=БОЛЬШЕ(10,5)', result: 'ИСТИНА' },
{ formula: '=БОЛЬШЕ(3,7)', result: 'ЛОЖЬ' },
],
},
{
name: 'МЕНЬШЕ',
category: 'Сравнение',
description: 'Проверяет, меньше ли первое число второго.',
syntax: 'МЕНЬШЕ(число1,число2)',
args: [
{ name: 'число1', description: 'Первое число' },
{ name: 'число2', description: 'Второе число' },
],
examples: [
{ formula: '=МЕНЬШЕ(3,7)', result: 'ИСТИНА' },
{ formula: '=МЕНЬШЕ(10,5)', result: 'ЛОЖЬ' },
],
},
{
name: 'ЕПУСТО',
category: 'Сравнение',
description: 'Проверяет, является ли значение пустым (null, undefined или пустая строка).',
syntax: 'ЕПУСТО(значение)',
args: [
{ name: 'значение', description: 'Значение для проверки' },
],
examples: [
{ formula: '=ЕПУСТО(A1)', result: 'ИСТИНА если A1 пуста' },
{ formula: '=ЕСЛИ(ЕПУСТО(A1),"Пусто",A1)', result: 'Значение A1 или "Пусто"' },
],
},
// Текстовые
{
name: 'СЦЕПИТЬ',
category: 'Текстовые',
description: 'Объединяет несколько текстовых строк в одну.',
syntax: 'СЦЕПИТЬ(текст1,текст2,...)',
args: [
{ name: екст1,текст2,...', description: 'Строки для объединения' },
],
examples: [
{ formula: '=СЦЕПИТЬ("Привет"," ","мир")', result: '"Привет мир"' },
{ formula: '=СЦЕПИТЬ(A1," - ",B1)', result: 'Значения A1 и B1 через дефис' },
],
},
{
name: 'ДЛСТР',
category: 'Текстовые',
description: 'Возвращает длину текстовой строки.',
syntax: 'ДЛСТР(текст)',
args: [
{ name: 'текст', description: 'Строка для подсчёта длины' },
],
examples: [
{ formula: '=ДЛСТР("Привет")', result: '6' },
],
},
{
name: 'ПРОПИСН',
category: 'Текстовые',
description: 'Преобразует текст в верхний регистр.',
syntax: 'ПРОПИСН(текст)',
args: [
{ name: 'текст', description: 'Строка для преобразования' },
],
examples: [
{ formula: '=ПРОПИСН("привет")', result: '"ПРИВЕТ"' },
],
},
{
name: 'СТРОЧН',
category: 'Текстовые',
description: 'Преобразует текст в нижний регистр.',
syntax: 'СТРОЧН(текст)',
args: [
{ name: 'текст', description: 'Строка для преобразования' },
],
examples: [
{ formula: '=СТРОЧН("ПРИВЕТ")', result: '"привет"' },
],
},
{
name: 'СЖПРОБЕЛЫ',
category: 'Текстовые',
description: 'Удаляет начальные и конечные пробелы из текста.',
syntax: 'СЖПРОБЕЛЫ(текст)',
args: [
{ name: 'текст', description: 'Строка для обработки' },
],
examples: [
{ formula: '=СЖПРОБЕЛЫ(" Привет ")', result: '"Привет"' },
],
},
]
const CATEGORY_ICONS: Record<string, FC<{ className?: string }>> = {
'Случайные числа': Hash,
'Математические': Calculator,
'Округление': Sigma,
'Логические': BookOpen,
'Сравнение': Search,
'Текстовые': Type,
}
const CATEGORY_ORDER = [
'Математические',
'Округление',
'Случайные числа',
'Логические',
'Сравнение',
'Текстовые',
]
export const FunctionsWikiPage: FC = () => {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
return FUNCTION_CATALOG.filter(fn => {
const matchesSearch = !q || fn.name.toLowerCase().includes(q) || fn.description.toLowerCase().includes(q)
const matchesCategory = !activeCategory || fn.category === activeCategory
return matchesSearch && matchesCategory
})
}, [search, activeCategory])
const grouped = useMemo(() => {
const map = new Map<string, FunctionInfo[]>()
for (const fn of filtered) {
const list = map.get(fn.category) || []
list.push(fn)
map.set(fn.category, list)
}
return CATEGORY_ORDER
.filter(cat => map.has(cat))
.map(cat => ({ category: cat, functions: map.get(cat)! }))
}, [filtered])
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="sticky top-0 z-10 border-b bg-white shadow-sm">
<div className="mx-auto max-w-5xl px-4 py-3">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-lg font-semibold text-gray-900">Справочник функций</h1>
<p className="text-sm text-gray-500">
{FUNCTION_CATALOG.length} функций в {CATEGORY_ORDER.length} категориях
</p>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-4 py-6">
{/* Operators reference */}
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="mb-2 text-sm font-semibold text-blue-800">Операторы сравнения (как в Excel)</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
<div><code className="font-medium text-blue-700">=</code> <span className="text-gray-600">равно</span></div>
<div><code className="font-medium text-blue-700">{'<>'}</code> <span className="text-gray-600">не равно</span></div>
<div><code className="font-medium text-blue-700">{'>'}</code> <span className="text-gray-600">больше</span></div>
<div><code className="font-medium text-blue-700">{'<'}</code> <span className="text-gray-600">меньше</span></div>
<div><code className="font-medium text-blue-700">{'>='}</code> <span className="text-gray-600">больше или равно</span></div>
<div><code className="font-medium text-blue-700">{'<='}</code> <span className="text-gray-600">меньше или равно</span></div>
</div>
<p className="mt-2 text-xs text-gray-500">Пример: <code className="text-blue-700">=ЕСЛИ(A1{'<>'}0, "Не ноль", "Ноль")</code></p>
</div>
{/* Search & Filters */}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Поиск функций..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setActiveCategory(null)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
!activeCategory
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Все
</button>
{CATEGORY_ORDER.map(cat => {
const Icon = CATEGORY_ICONS[cat]
return (
<button
key={cat}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={`flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors ${
activeCategory === cat
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{Icon && <Icon className="h-3 w-3" />}
{cat}
</button>
)
})}
</div>
</div>
{/* Results */}
{grouped.length === 0 ? (
<div className="py-12 text-center text-gray-500">
Функции не найдены. Попробуйте изменить запрос.
</div>
) : (
<div className="space-y-8">
{grouped.map(({ category, functions }) => {
const Icon = CATEGORY_ICONS[category]
return (
<section key={category}>
<div className="mb-3 flex items-center gap-2">
{Icon && <Icon className="h-5 w-5 text-gray-400" />}
<h2 className="text-base font-semibold text-gray-800">{category}</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{functions.length}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{functions.map(fn => (
<FunctionCard key={fn.name} fn={fn} />
))}
</div>
</section>
)
})}
</div>
)}
</div>
</div>
)
}
const FunctionCard: FC<{ fn: FunctionInfo }> = ({ fn }) => {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md">
<div className="mb-2 flex items-start justify-between gap-2">
<code className="rounded bg-blue-50 px-2 py-0.5 text-sm font-bold text-blue-700">
{fn.name}
</code>
{fn.notes && (
<span className="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-600">
{fn.notes.length > 30 ? fn.notes.slice(0, 30) + '...' : fn.notes}
</span>
)}
</div>
<p className="mb-2 text-sm text-gray-600">{fn.description}</p>
{/* Syntax */}
<div className="mb-2">
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-gray-400">
Синтаксис
</div>
<code className="block rounded bg-gray-50 px-2 py-1 text-xs text-gray-700">
={fn.syntax}
</code>
</div>
{/* Args */}
{fn.args.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-gray-400">
Аргументы
</div>
<div className="space-y-0.5">
{fn.args.map(arg => (
<div key={arg.name} className="flex gap-1.5 text-xs">
<code className="shrink-0 font-medium text-gray-700">{arg.name}</code>
{arg.optional && (
<span className="shrink-0 text-[10px] text-gray-400">(необяз.)</span>
)}
<span className="text-gray-500"> {arg.description}</span>
</div>
))}
</div>
</div>
)}
{/* Examples */}
<div>
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-gray-400">
Примеры
</div>
<div className="space-y-1">
{fn.examples.map((ex, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<code className="rounded bg-green-50 px-1.5 py-0.5 text-green-700">{ex.formula}</code>
<span className="text-gray-400"></span>
<span className="text-gray-600">{ex.result}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { FunctionsWikiPage } from './functions-wiki/Page'
import { StandardsManagementPage } from './standards-management/Page'
import { TemplateEditPage } from './templates/edit/Page'
import { ElementsCreation } from './templates/elements/Page'
@@ -6,6 +7,7 @@ import { ProtocolCreation } from './templates/protocols/Page'
export {
ElementsCreation,
FunctionsWikiPage,
ProtocolCreation,
StandardsManagementPage,
TemplateEditPage,

View File

@@ -38,6 +38,7 @@ import {
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import clsx from 'clsx'
import {
BookOpen,
CheckSquare,
Copy,
Edit3,
@@ -49,6 +50,7 @@ import {
X,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from 'shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card'
import { Checkbox } from 'shared/ui/checkbox'
@@ -346,6 +348,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
}
const TemplatesPageContent = () => {
const navigate = useNavigate()
const {
templates,
deleteTemplate,
@@ -935,6 +938,15 @@ const TemplatesPageContent = () => {
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<h1 className="text-lg font-semibold">Карточки протоколов</h1>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/functions-wiki')}
className="ml-2"
>
<BookOpen className="mr-1 h-4 w-4" />
Справочник функций
</Button>
<div className="ml-auto flex items-center gap-2">
{/* Кнопка сохранения порядка */}

View File

@@ -14,16 +14,24 @@
"outDir": "./dist/",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"shared/*": ["./shared/*"]
"@/*": [
"./src/*"
],
"shared/*": [
"./shared/*"
]
},
"skipLibCheck": true
},
"ts-node": {
"compilerOptions": {
"module": "CommonJS"
}
},
"exclude": ["node_modules", "dist", "build", "coverage"]
"exclude": [
"node_modules",
"dist",
"build",
"coverage"
]
}