chore: add project files for CI and release

This commit is contained in:
Yury Rebrov 2026-01-29 12:21:43 +03:00
parent 9c62260b70
commit d24af36b6b
122 changed files with 15592 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# env
.env
.env.*
*.env
# OS
.DS_Store
# Node
node_modules
dist
build
# Rust
/desktop/src-tauri/target
/desktop/src-tauri/.cargo
# Logs
*.log

30
PAPA YU.command Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
# PAPA YU — запуск приложения (основная кнопка)
# Двойной клик: сразу запускает программу. Сборка не выполняется.
# Путь к .app вычисляется от каталога, в котором лежит этот скрипт (устойчиво к текущей директории).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR"
BUNDLE_DIR="$ROOT/desktop/src-tauri/target/release/bundle/macos"
find_app() {
[ -d "$BUNDLE_DIR/PAPA YU.app" ] && echo "$BUNDLE_DIR/PAPA YU.app" && return
for f in "$BUNDLE_DIR"/*.app; do
[ -d "$f" ] && echo "$f" && return
done
echo ""
}
APP=$(find_app)
if [ -n "$APP" ]; then
open "$APP"
exit 0
fi
echo ""
echo " PAPA YU не найден."
echo " Для первой сборки откройте:"
echo " «PAPA YU — Сборка и запуск.command»"
echo ""
read -n 1 -s -r -p " Нажмите любую клавишу..."
exit 1

3
desktop-core/README.md Normal file
View File

@ -0,0 +1,3 @@
# desktop-core (архив)
Каталог оставлен для совместимости. Содержимое `tools/project-auditor/` не используется основным приложением PAPA YU. В будущем здесь могут быть общие утилиты для desktop-сборки.

4
desktop/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

5449
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
walkdir = "2"
chrono = "0.4"

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"dialog:allow-open",
"core:event:allow-listen",
"updater:default",
"process:allow-restart"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,745 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;
use tauri::Emitter;
use crate::types::{
Action, ActionKind, AnalyzeReport, Finding, LlmContext, ProjectContext, ProjectSignal,
ProjectStructure, Recommendation, ReportStats,
};
const MAX_FILES: u64 = 50_000;
const MAX_DURATION_SECS: u64 = 60;
const TOP_EXTENSIONS_N: usize = 15;
const MAX_DEPTH_WARN: u32 = 6;
const ROOT_FILES_WARN: u64 = 20;
const EXCLUDED_DIRS: &[&str] = &[
".git", "node_modules", "dist", "build", ".next", "target", ".cache", "coverage",
];
const MARKER_README: &[&str] = &["README", "readme", "Readme"];
const MARKER_VITE: &[&str] = &["vite.config.js", "vite.config.ts", "vite.config.mjs"];
#[derive(Default)]
struct ScanState {
file_count: u64,
dir_count: u64,
total_size_bytes: u64,
extensions: HashMap<String, u64>,
has_readme: bool,
has_package_json: bool,
has_cargo_toml: bool,
has_env: bool,
has_docker: bool,
has_tsconfig: bool,
has_vite: bool,
has_next: bool,
has_gitignore: bool,
has_license: bool,
has_eslint: bool,
has_prettier: bool,
has_tests_dir: bool,
has_src: bool,
has_components: bool,
has_pages: bool,
has_requirements_txt: bool,
has_pyproject: bool,
has_setup_py: bool,
package_json_count: u32,
cargo_toml_count: u32,
root_file_count: u64,
root_dirs: HashSet<String>,
max_depth: u32,
}
const PROGRESS_EVENT: &str = "analyze_progress";
#[tauri::command]
pub fn analyze_project(window: tauri::Window, path: String) -> Result<AnalyzeReport, String> {
let root = PathBuf::from(&path);
if !root.exists() {
return Err("Путь не существует".to_string());
}
if !root.is_dir() {
return Err("Путь не является папкой".to_string());
}
let _ = window.emit(PROGRESS_EVENT, "Сканирую структуру…");
let deadline = Instant::now() + std::time::Duration::from_secs(MAX_DURATION_SECS);
let mut state = ScanState::default();
scan_dir(&root, &root, 0, &mut state, &deadline)?;
let _ = window.emit(PROGRESS_EVENT, "Анализирую архитектуру…");
let top_extensions: Vec<(String, u64)> = {
let mut v: Vec<_> = state.extensions.iter().map(|(k, v)| (k.clone(), *v)).collect();
v.sort_by(|a, b| b.1.cmp(&a.1));
v.into_iter().take(TOP_EXTENSIONS_N).collect()
};
let stats = ReportStats {
file_count: state.file_count,
dir_count: state.dir_count,
total_size_bytes: state.total_size_bytes,
top_extensions,
max_depth: state.max_depth as u64,
};
let structure = build_structure(&state);
let mut findings: Vec<Finding> = Vec::new();
let mut recommendations: Vec<Recommendation> = Vec::new();
let mut signals: Vec<ProjectSignal> = Vec::new();
if state.has_env {
findings.push(Finding {
severity: "high".to_string(),
title: "Риск секретов".to_string(),
details: "Обнаружены файлы .env или .env.* — не коммитьте секреты в репозиторий.".to_string(),
});
signals.push(ProjectSignal {
category: "security".to_string(),
level: "high".to_string(),
message: "Есть .env файл — риск утечки секретов.".to_string(),
});
}
if !state.has_readme {
recommendations.push(Recommendation {
title: "Добавить README".to_string(),
details: "Опишите проект и инструкцию по запуску.".to_string(),
priority: "medium".to_string(),
effort: "low".to_string(),
impact: "high".to_string(),
});
signals.push(ProjectSignal {
category: "quality".to_string(),
level: "warn".to_string(),
message: "Нет README.".to_string(),
});
}
if !state.has_gitignore {
recommendations.push(Recommendation {
title: "Добавить .gitignore".to_string(),
details: "Исключите артефакты сборки и зависимости из репозитория.".to_string(),
priority: "medium".to_string(),
effort: "low".to_string(),
impact: "medium".to_string(),
});
signals.push(ProjectSignal {
category: "quality".to_string(),
level: "warn".to_string(),
message: "Нет .gitignore.".to_string(),
});
}
if !state.has_license {
recommendations.push(Recommendation {
title: "Указать лицензию".to_string(),
details: "Добавьте LICENSE или LICENSE.md.".to_string(),
priority: "low".to_string(),
effort: "low".to_string(),
impact: "medium".to_string(),
});
signals.push(ProjectSignal {
category: "quality".to_string(),
level: "info".to_string(),
message: "Нет файла лицензии.".to_string(),
});
}
if state.has_src && !state.has_tests_dir {
recommendations.push(Recommendation {
title: "Добавить тесты".to_string(),
details: "Есть src/, но нет папки tests/ — добавьте базовые тесты.".to_string(),
priority: "high".to_string(),
effort: "medium".to_string(),
impact: "high".to_string(),
});
signals.push(ProjectSignal {
category: "structure".to_string(),
level: "warn".to_string(),
message: "Есть src/, нет tests/.".to_string(),
});
}
if state.has_components && !state.has_pages && state.has_package_json {
recommendations.push(Recommendation {
title: "Проверить структуру фронтенда".to_string(),
details: "Есть components/, но нет pages/ — возможно, маршруты или страницы в другом месте.".to_string(),
priority: "low".to_string(),
effort: "low".to_string(),
impact: "low".to_string(),
});
}
if state.root_file_count >= ROOT_FILES_WARN {
findings.push(Finding {
severity: "warn".to_string(),
title: "Много файлов в корне".to_string(),
details: format!("В корне {} файлов — рассмотрите группировку по папкам.", state.root_file_count),
});
signals.push(ProjectSignal {
category: "structure".to_string(),
level: "warn".to_string(),
message: "Слишком много файлов в корне проекта.".to_string(),
});
}
if state.max_depth >= MAX_DEPTH_WARN {
findings.push(Finding {
severity: "warn".to_string(),
title: "Глубокая вложенность".to_string(),
details: format!("Вложенность до {} уровней — усложняет навигацию.", state.max_depth),
});
signals.push(ProjectSignal {
category: "structure".to_string(),
level: "warn".to_string(),
message: "Глубокая вложенность папок.".to_string(),
});
}
if state.has_package_json && !state.has_eslint && !state.has_cargo_toml {
recommendations.push(Recommendation {
title: "Добавить линтер".to_string(),
details: "Рекомендуется ESLint (и при необходимости Prettier) для JavaScript/TypeScript.".to_string(),
priority: "medium".to_string(),
effort: "low".to_string(),
impact: "medium".to_string(),
});
}
if state.has_cargo_toml && !state.has_eslint {
recommendations.push(Recommendation {
title: "Использовать Clippy".to_string(),
details: "Добавьте в CI или pre-commit: cargo clippy.".to_string(),
priority: "medium".to_string(),
effort: "low".to_string(),
impact: "medium".to_string(),
});
}
if !state.has_package_json && !state.has_cargo_toml && !state.has_pyproject && !state.has_requirements_txt {
findings.push(Finding {
severity: "warn".to_string(),
title: "Неопределён тип проекта".to_string(),
details: "Не найдены привычные манифесты (package.json, Cargo.toml, pyproject.toml).".to_string(),
});
}
if state.file_count > 30_000 || state.dir_count > 5_000 {
recommendations.push(Recommendation {
title: "Сузить область анализа".to_string(),
details: "Очень много файлов или папок — добавьте исключения или выберите подпапку.".to_string(),
priority: "medium".to_string(),
effort: "low".to_string(),
impact: "low".to_string(),
});
}
let _ = window.emit(PROGRESS_EVENT, "Формирую вывод…");
let recommendations = enrich_recommendations(recommendations);
let project_context = build_project_context(&state, &findings, &signals);
let actions = build_actions(state.has_readme, state.has_tests_dir, state.has_gitignore);
let narrative = build_narrative(&state, &structure, &findings, &recommendations);
let report = AnalyzeReport {
path: path.clone(),
narrative: narrative.clone(),
stats: stats.clone(),
structure: structure.clone(),
project_context: project_context.clone(),
findings: findings.clone(),
recommendations: recommendations.clone(),
actions: actions.clone(),
signals: signals.clone(),
report_md: String::new(),
llm_context: LlmContext {
concise_summary: String::new(),
key_risks: Vec::new(),
top_recommendations: Vec::new(),
signals: Vec::new(),
},
};
let report_md = build_markdown_report(&report);
let llm_context = build_llm_context(&report);
Ok(AnalyzeReport {
path: path.clone(),
narrative: report.narrative,
stats: report.stats,
structure: report.structure,
project_context: report.project_context,
findings: report.findings,
recommendations: report.recommendations,
actions: report.actions,
signals: report.signals,
report_md,
llm_context,
})
}
fn build_actions(has_readme: bool, has_tests: bool, has_gitignore: bool) -> Vec<Action> {
let mut actions = vec![];
if !has_readme {
actions.push(Action {
id: "add-readme".into(),
title: "Добавить README.md".into(),
description: "В проекте отсутствует README.md".into(),
kind: ActionKind::CreateFile,
path: "README.md".into(),
content: Some("# Project\n\nDescribe your project.\n".into()),
});
}
if !has_tests {
actions.push(Action {
id: "add-tests-dir".into(),
title: "Создать папку tests/".into(),
description: "В проекте нет tests/ (минимальная структура для тестов)".into(),
kind: ActionKind::CreateDir,
path: "tests".into(),
content: None,
});
}
if !has_gitignore {
actions.push(Action {
id: "add-gitignore".into(),
title: "Добавить .gitignore".into(),
description: "Рекомендуется добавить базовый .gitignore".into(),
kind: ActionKind::CreateFile,
path: ".gitignore".into(),
content: Some("node_modules/\ndist/\nbuild/\n.target/\n.DS_Store\n".into()),
});
}
actions
}
fn enrich_recommendations(mut recs: Vec<Recommendation>) -> Vec<Recommendation> {
for r in &mut recs {
let (p, e, i) = if r.title.contains("README") {
("high", "low", "high")
} else if r.title.contains("тест") || r.title.contains("тесты") || r.title.contains("Add tests") || r.title.contains("tests") {
("high", "medium", "high")
} else if r.title.contains(".gitignore") {
("medium", "low", "medium")
} else if r.title.contains(".env") || r.title.contains("секрет") {
("high", "low", "high")
} else if r.title.contains("лицензи") {
("low", "low", "medium")
} else if r.title.contains("линтер") || r.title.contains("Clippy") {
("medium", "low", "medium")
} else {
(r.priority.as_str(), r.effort.as_str(), r.impact.as_str())
};
r.priority = p.to_string();
r.effort = e.to_string();
r.impact = i.to_string();
}
recs
}
fn build_project_context(
state: &ScanState,
findings: &[Finding],
signals: &[ProjectSignal],
) -> ProjectContext {
let risk_level = if state.has_env
|| signals.iter().any(|s| s.category == "security" && s.level == "high")
{
"High"
} else if findings.len() > 5
|| signals.iter().any(|s| s.level == "warn")
{
"Medium"
} else {
"Low"
};
let complexity = if state.file_count > 5000 || state.dir_count > 500 || state.max_depth > 8 {
"High"
} else if state.file_count > 800 || state.dir_count > 120 {
"Medium"
} else {
"Low"
};
let maturity = if state.has_readme && (state.has_tests_dir || state.has_eslint) {
"Production-like"
} else if state.has_readme {
"MVP"
} else {
"Prototype"
};
let mut stack = Vec::new();
if state.has_package_json {
stack.push("Node.js".to_string());
}
if state.has_cargo_toml {
stack.push("Rust".to_string());
}
if state.has_vite {
stack.push("Vite".to_string());
}
if state.has_next {
stack.push("Next.js".to_string());
}
if state.has_pyproject || state.has_requirements_txt {
stack.push("Python".to_string());
}
if stack.is_empty() {
stack.push("Unknown".to_string());
}
let domain = if state.has_next || state.has_vite {
"frontend"
} else if state.has_cargo_toml {
"systems"
} else if state.has_package_json {
"fullstack"
} else {
"general"
}
.to_string();
ProjectContext {
stack,
domain,
maturity: maturity.to_string(),
complexity: complexity.to_string(),
risk_level: risk_level.to_string(),
}
}
fn build_markdown_report(report: &AnalyzeReport) -> String {
let mut md = String::new();
md.push_str("# PAPA YU — отчёт анализа проекта\n\n");
md.push_str(&report.narrative);
md.push_str("\n\n---\n\n");
md.push_str("## Статистика\n\n");
md.push_str(&format!(
"- Файлов: {}\n- Папок: {}\n- Max depth: {}\n- Размер: {} Б\n\n",
report.stats.file_count,
report.stats.dir_count,
report.stats.max_depth,
report.stats.total_size_bytes
));
md.push_str("## Контекст проекта\n\n");
md.push_str(&format!(
"- Стек: {}\n- Зрелость: {}\n- Сложность: {}\n- Риск: {}\n\n",
report.project_context.stack.join(", "),
report.project_context.maturity,
report.project_context.complexity,
report.project_context.risk_level
));
if !report.findings.is_empty() {
md.push_str("## Находки\n\n");
for f in &report.findings {
md.push_str(&format!("- **{}**: {}\n", f.title, f.details));
}
md.push_str("\n");
}
if !report.recommendations.is_empty() {
md.push_str("## Рекомендации\n\n");
for r in &report.recommendations {
md.push_str(&format!(
"- **{}** [{} / effort:{} / impact:{}]\n {}\n",
r.title, r.priority, r.effort, r.impact, r.details
));
}
}
md
}
fn build_llm_context(report: &AnalyzeReport) -> LlmContext {
let concise_summary = format!(
"{}; {}; {} файлов, {} папок. Риск: {}, зрелость: {}.",
report.structure.project_type,
report.structure.architecture,
report.stats.file_count,
report.stats.dir_count,
report.project_context.risk_level,
report.project_context.maturity
);
let key_risks: Vec<String> = report
.findings
.iter()
.filter(|f| f.severity == "high")
.map(|f| format!("{}: {}", f.title, f.details))
.collect();
let top_recommendations: Vec<String> = report
.recommendations
.iter()
.take(5)
.map(|r| format!("[{}] {}", r.priority, r.title))
.collect();
LlmContext {
concise_summary,
key_risks,
top_recommendations,
signals: report.signals.clone(),
}
}
fn build_structure(state: &ScanState) -> ProjectStructure {
let mut project_type = String::new();
let mut architecture = String::new();
let mut structure_notes: Vec<String> = Vec::new();
let is_monorepo = state.package_json_count > 1 || state.cargo_toml_count > 1;
if state.has_cargo_toml {
project_type = "Rust / Cargo".to_string();
architecture = "Rust-проект".to_string();
if is_monorepo {
project_type = "Rust monorepo".to_string();
}
}
if state.has_package_json {
if !project_type.is_empty() {
project_type = format!("{} + Node", project_type);
} else if state.has_next {
project_type = "Next.js".to_string();
architecture = "React (Next.js) fullstack".to_string();
} else if state.has_vite {
project_type = "React + Vite".to_string();
architecture = "Frontend SPA (Vite)".to_string();
} else {
project_type = "Node.js".to_string();
architecture = "Node / frontend или backend".to_string();
}
if is_monorepo && !project_type.contains("monorepo") {
project_type = format!("{} (monorepo)", project_type);
}
}
if state.has_pyproject || state.has_requirements_txt || state.has_setup_py {
if !project_type.is_empty() {
project_type = format!("{} + Python", project_type);
} else {
project_type = "Python".to_string();
architecture = "Python-проект (Django/FastAPI или скрипты)".to_string();
}
}
if project_type.is_empty() {
project_type = "Неопределён".to_string();
architecture = "Тип по манифестам не определён".to_string();
}
if state.has_src && state.has_tests_dir {
structure_notes.push("Есть src/ и tests/ — хорошее разделение.".to_string());
} else if state.has_src && !state.has_tests_dir {
structure_notes.push("Есть src/, нет tests/ — стоит добавить тесты.".to_string());
}
if state.root_file_count >= ROOT_FILES_WARN {
structure_notes.push("Много файлов в корне — структура упрощённая.".to_string());
}
if state.max_depth >= MAX_DEPTH_WARN {
structure_notes.push("Глубокая вложенность папок.".to_string());
}
if structure_notes.is_empty() {
structure_notes.push("Структура без явного разделения на домены.".to_string());
}
ProjectStructure {
project_type,
architecture,
structure_notes,
}
}
fn build_narrative(
state: &ScanState,
structure: &ProjectStructure,
findings: &[Finding],
recommendations: &[Recommendation],
) -> String {
let mut parts = Vec::new();
parts.push("Я проанализировал ваш проект.".to_string());
parts.push(format!(
"Это {} ({}).",
structure.project_type.to_lowercase(),
structure.architecture
));
if !structure.structure_notes.is_empty() {
parts.push(structure.structure_notes.join(" "));
}
let size_label = if state.file_count < 50 {
"небольшой"
} else if state.file_count < 500 {
"среднего размера"
} else {
"крупный"
};
parts.push(format!(
"По размеру — {} проект: {} файлов, {} папок.",
size_label, state.file_count, state.dir_count
));
if !findings.is_empty() {
parts.push("".to_string());
parts.push("Основные проблемы:".to_string());
for f in findings.iter().take(7) {
parts.push(format!(" {}", f.title));
}
}
if !recommendations.is_empty() {
parts.push("".to_string());
parts.push("Я бы рекомендовал начать с:".to_string());
for (i, r) in recommendations.iter().take(5).enumerate() {
parts.push(format!("{}. {}", i + 1, r.title));
}
}
parts.join("\n\n")
}
fn scan_dir(
root: &Path,
dir: &Path,
depth: u32,
state: &mut ScanState,
deadline: &Instant,
) -> Result<(), String> {
if Instant::now() > *deadline {
return Err("Превышено время анализа (таймаут)".to_string());
}
if state.file_count >= MAX_FILES {
return Err("Превышен лимит количества файлов".to_string());
}
if depth > state.max_depth {
state.max_depth = depth;
}
let is_root = dir == root;
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
for entry in entries.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_symlink() {
continue;
}
if meta.is_dir() {
state.dir_count += 1;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if is_root {
state.root_dirs.insert(name.to_lowercase());
}
let name_lower = name.to_lowercase();
if EXCLUDED_DIRS.contains(&name) {
continue;
}
if name_lower == "src" {
state.has_src = true;
}
if name_lower == "tests" || name_lower == "test" || name_lower == "__tests__" {
state.has_tests_dir = true;
}
if name_lower == "components" {
state.has_components = true;
}
if name_lower == "pages" || name_lower == "app" {
state.has_pages = true;
}
scan_dir(root, &path, depth + 1, state, deadline)?;
continue;
}
state.file_count += 1;
if state.file_count >= MAX_FILES {
return Err("Превышен лимит количества файлов".to_string());
}
if is_root {
state.root_file_count += 1;
}
state.total_size_bytes = state.total_size_bytes.saturating_add(meta.len());
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let name_lower = name.to_lowercase();
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_string();
*state.extensions.entry(ext).or_insert(0) += 1;
}
if name_lower == "package.json" {
state.has_package_json = true;
state.package_json_count += 1;
}
if name_lower == "cargo.toml" {
state.has_cargo_toml = true;
state.cargo_toml_count += 1;
}
if name_lower == "tsconfig.json" {
state.has_tsconfig = true;
}
if name_lower == "dockerfile" || name_lower == "docker-compose.yml" {
state.has_docker = true;
}
if name_lower.starts_with(".env") {
state.has_env = true;
}
if name_lower == ".gitignore" {
state.has_gitignore = true;
}
if name_lower == "license" || name_lower == "license.md" || name_lower.starts_with("license.") {
state.has_license = true;
}
if name_lower == "eslint.config.js" || name_lower == ".eslintrc" || name_lower.starts_with(".eslintrc") {
state.has_eslint = true;
}
if name_lower == ".prettierrc" || name_lower == "prettier.config" || name_lower.starts_with("prettier.config") {
state.has_prettier = true;
}
if name_lower == "next.config.js" || name_lower == "next.config.mjs" || name_lower == "next.config.ts" {
state.has_next = true;
}
if name_lower == "requirements.txt" {
state.has_requirements_txt = true;
}
if name_lower == "pyproject.toml" {
state.has_pyproject = true;
}
if name_lower == "setup.py" {
state.has_setup_py = true;
}
for m in MARKER_README {
if name_lower.starts_with(&m.to_lowercase()) {
state.has_readme = true;
break;
}
}
for m in MARKER_VITE {
if name_lower == *m {
state.has_vite = true;
break;
}
}
}
}
Ok(())
}

View File

@ -0,0 +1,249 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, Window};
use crate::types::{Action, ActionKind, ApplyResult};
const PROGRESS_EVENT: &str = "analyze_progress";
fn app_data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|_| "app_data_dir_unavailable".to_string())
}
fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
let rel_path = PathBuf::from(rel);
if rel_path.is_absolute() {
return Err("absolute_path_denied".into());
}
if rel.contains("..") {
return Err("path_traversal_denied".into());
}
Ok(base.join(rel_path))
}
fn snapshot_paths(
session_dir: &Path,
project_root: &Path,
targets: &[PathBuf],
) -> Result<(), String> {
let snap_dir = session_dir.join("snapshot");
fs::create_dir_all(&snap_dir).map_err(|e| e.to_string())?;
for t in targets {
let abs = project_root.join(t);
let snap = snap_dir.join(t);
if abs.is_dir() {
fs::create_dir_all(&snap).map_err(|e| e.to_string())?;
continue;
}
if abs.exists() {
if let Some(parent) = snap.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(&abs, &snap).map_err(|e| e.to_string())?;
} else {
let missing_marker = snap_dir.join(".missing").join(t);
if let Some(parent) = missing_marker.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&missing_marker, b"").map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn revert_snapshot(session_dir: &Path, project_root: &Path) -> Result<Vec<String>, String> {
let snap_dir = session_dir.join("snapshot");
if !snap_dir.exists() {
return Err("snapshot_missing".into());
}
let mut restored = vec![];
for entry in walkdir::WalkDir::new(&snap_dir)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_type().is_dir() {
continue;
}
let snap_path = entry.path().to_path_buf();
let rel = snap_path
.strip_prefix(&snap_dir)
.map_err(|e| e.to_string())?;
let rel_str = rel.to_string_lossy();
if rel_str.starts_with(".missing/") || rel_str.starts_with(".missing\\") {
let orig: &str = rel_str
.strip_prefix(".missing/")
.or_else(|| rel_str.strip_prefix(".missing\\"))
.unwrap_or(&rel_str);
let abs = project_root.join(orig);
if abs.exists() {
fs::remove_file(&abs).map_err(|e| e.to_string())?;
restored.push(orig.to_string());
}
continue;
}
let abs = project_root.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(&snap_path, &abs).map_err(|e| e.to_string())?;
restored.push(rel.to_string_lossy().to_string());
}
Ok(restored)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyPayload {
pub path: String,
pub actions: Vec<Action>,
}
#[tauri::command]
pub async fn apply_actions(window: Window, app: AppHandle, payload: ApplyPayload) -> ApplyResult {
let project_root = PathBuf::from(&payload.path);
if !project_root.exists() || !project_root.is_dir() {
return ApplyResult {
ok: false,
session_id: String::new(),
applied: vec![],
skipped: payload.actions.iter().map(|a| a.id.clone()).collect(),
error: Some("path_invalid".into()),
error_code: Some("PATH_INVALID".into()),
undo_available: false,
};
}
let data_dir = match app_data_dir(&app) {
Ok(d) => d,
Err(e) => {
return ApplyResult {
ok: false,
session_id: String::new(),
applied: vec![],
skipped: vec![],
error: Some(e),
error_code: Some("APP_DATA_DIR".into()),
undo_available: false,
};
}
};
let session_id = format!("{}", chrono::Utc::now().timestamp_millis());
let session_dir = data_dir.join("history").join(&session_id);
if fs::create_dir_all(&session_dir).is_err() {
return ApplyResult {
ok: false,
session_id: session_id.clone(),
applied: vec![],
skipped: vec![],
error: Some("HISTORY_CREATE_FAILED".into()),
error_code: Some("HISTORY_CREATE_FAILED".into()),
undo_available: false,
};
}
let _ = window.emit(PROGRESS_EVENT, "Готовлю откат (snapshot)…");
let targets: Vec<PathBuf> = payload.actions.iter().map(|a| PathBuf::from(&a.path)).collect();
if let Err(e) = snapshot_paths(&session_dir, &project_root, &targets) {
return ApplyResult {
ok: false,
session_id: session_id.clone(),
applied: vec![],
skipped: payload.actions.iter().map(|a| a.id.clone()).collect(),
error: Some(e),
error_code: Some("SNAPSHOT_FAILED".into()),
undo_available: false,
};
}
let _ = window.emit(PROGRESS_EVENT, "Применяю изменения…");
let mut applied = vec![];
let result_apply = (|| -> Result<(), String> {
for a in &payload.actions {
let abs = safe_join(&project_root, &a.path)?;
match a.kind {
ActionKind::CreateDir => {
fs::create_dir_all(&abs).map_err(|e| e.to_string())?;
}
ActionKind::DeleteDir => {
if abs.exists() {
fs::remove_dir_all(&abs).map_err(|e| e.to_string())?;
}
}
ActionKind::CreateFile | ActionKind::UpdateFile => {
let content = a
.content
.clone()
.ok_or_else(|| "content_missing".to_string())?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&abs, content.as_bytes()).map_err(|e| e.to_string())?;
}
ActionKind::DeleteFile => {
if abs.exists() {
fs::remove_file(&abs).map_err(|e| e.to_string())?;
}
}
}
applied.push(a.id.clone());
}
Ok(())
})();
if let Err(err) = result_apply {
let _ = window.emit(PROGRESS_EVENT, "Обнаружена ошибка. Откатываю изменения…");
let _ = revert_snapshot(&session_dir, &project_root);
let skipped: Vec<String> = payload
.actions
.iter()
.map(|a| a.id.clone())
.filter(|id| !applied.contains(id))
.collect();
return ApplyResult {
ok: false,
session_id,
applied,
skipped,
error: Some(err),
error_code: Some("APPLY_FAILED_ROLLED_BACK".into()),
undo_available: false,
};
}
let _ = fs::write(
data_dir.join("history").join("last_session.txt"),
session_id.as_bytes(),
);
let _ = window.emit(PROGRESS_EVENT, "Готово. Изменения применены.");
ApplyResult {
ok: true,
session_id,
applied,
skipped: vec![],
error: None,
error_code: None,
undo_available: true,
}
}

View File

@ -0,0 +1,21 @@
use serde::Serialize;
use tauri::{AppHandle, Manager};
#[derive(Debug, Serialize)]
pub struct AppInfo {
pub version: String,
pub app_data_dir: Option<String>,
pub app_config_dir: Option<String>,
}
#[tauri::command]
pub fn get_app_info(app: AppHandle) -> AppInfo {
let version = app.package_info().version.to_string();
let app_data_dir = app.path().app_data_dir().ok().map(|p| p.to_string_lossy().into_owned());
let app_config_dir = app.path().app_config_dir().ok().map(|p| p.to_string_lossy().into_owned());
AppInfo {
version,
app_data_dir,
app_config_dir,
}
}

View File

@ -0,0 +1,11 @@
mod analyze_project;
mod apply_actions;
mod get_app_info;
mod preview_actions;
mod undo_last;
pub use analyze_project::analyze_project;
pub use apply_actions::apply_actions;
pub use get_app_info::get_app_info;
pub use preview_actions::preview_actions;
pub use undo_last::undo_last;

View File

@ -0,0 +1,138 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Window};
use crate::types::{Action, ActionKind, DiffItem, PreviewResult};
const PROGRESS_EVENT: &str = "analyze_progress";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewPayload {
pub path: String,
pub actions: Vec<Action>,
}
fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
let rel_path = PathBuf::from(rel);
if rel_path.is_absolute() {
return Err("absolute_path_denied".into());
}
if rel.contains("..") {
return Err("path_traversal_denied".into());
}
Ok(base.join(rel_path))
}
fn read_text_if_exists(p: &Path) -> Option<String> {
if !p.exists() || p.is_dir() {
return None;
}
let bytes = fs::read(p).ok()?;
if bytes.len() > 200_000 {
return Some("[слишком большой файл для предпросмотра]".into());
}
String::from_utf8(bytes).ok()
}
fn summarize(kind: &str, path: &str) -> String {
match kind {
"create" => format!("Создать файл {}", path),
"update" => format!("Обновить файл {}", path),
"delete" => format!("Удалить файл {}", path),
"mkdir" => format!("Создать папку {}", path),
"rmdir" => format!("Удалить папку {}", path),
_ => format!("Изменение {}", path),
}
}
#[tauri::command]
pub async fn preview_actions(
window: Window,
_app: AppHandle,
payload: PreviewPayload,
) -> PreviewResult {
let project_root = PathBuf::from(&payload.path);
if !project_root.exists() || !project_root.is_dir() {
return PreviewResult {
ok: false,
diffs: vec![],
error: Some("path_invalid".into()),
error_code: Some("PATH_INVALID".into()),
};
}
let _ = window.emit(PROGRESS_EVENT, "Готовлю предпросмотр изменений…");
let mut diffs: Vec<DiffItem> = vec![];
for a in payload.actions {
let abs = match safe_join(&project_root, &a.path) {
Ok(p) => p,
Err(e) => {
return PreviewResult {
ok: false,
diffs: vec![],
error: Some(e),
error_code: Some("PATH_DENIED".into()),
};
}
};
match a.kind {
ActionKind::CreateDir => {
diffs.push(DiffItem {
path: a.path.clone(),
kind: "mkdir".into(),
before: None,
after: None,
summary: summarize("mkdir", &a.path),
});
}
ActionKind::DeleteDir => {
diffs.push(DiffItem {
path: a.path.clone(),
kind: "rmdir".into(),
before: None,
after: None,
summary: summarize("rmdir", &a.path),
});
}
ActionKind::CreateFile => {
diffs.push(DiffItem {
path: a.path.clone(),
kind: "create".into(),
before: None,
after: a.content.clone(),
summary: summarize("create", &a.path),
});
}
ActionKind::UpdateFile => {
diffs.push(DiffItem {
path: a.path.clone(),
kind: "update".into(),
before: read_text_if_exists(&abs),
after: a.content.clone(),
summary: summarize("update", &a.path),
});
}
ActionKind::DeleteFile => {
diffs.push(DiffItem {
path: a.path.clone(),
kind: "delete".into(),
before: read_text_if_exists(&abs),
after: None,
summary: summarize("delete", &a.path),
});
}
}
}
PreviewResult {
ok: true,
diffs,
error: None,
error_code: None,
}
}

View File

@ -0,0 +1,124 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager, Window};
use crate::types::UndoResult;
const PROGRESS_EVENT: &str = "analyze_progress";
fn app_data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|_| "app_data_dir_unavailable".to_string())
}
fn revert_snapshot(
session_dir: &PathBuf,
project_root: &PathBuf,
) -> Result<Vec<String>, String> {
let snap_dir = session_dir.join("snapshot");
if !snap_dir.exists() {
return Err("snapshot_missing".into());
}
let mut restored = vec![];
for entry in walkdir::WalkDir::new(&snap_dir)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_type().is_dir() {
continue;
}
let snap_path = entry.path().to_path_buf();
let rel = snap_path
.strip_prefix(&snap_dir)
.map_err(|e| e.to_string())?;
let rel_str = rel.to_string_lossy();
if rel_str.starts_with(".missing/") || rel_str.starts_with(".missing\\") {
let orig: &str = rel_str
.strip_prefix(".missing/")
.or_else(|| rel_str.strip_prefix(".missing\\"))
.unwrap_or(&rel_str);
let abs = project_root.join(orig);
if abs.exists() {
fs::remove_file(&abs).map_err(|e| e.to_string())?;
restored.push(orig.to_string());
}
continue;
}
let abs = project_root.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(&snap_path, &abs).map_err(|e| e.to_string())?;
restored.push(rel.to_string_lossy().to_string());
}
Ok(restored)
}
#[tauri::command]
pub async fn undo_last(window: Window, app: AppHandle, path: String) -> UndoResult {
let project_root = PathBuf::from(&path);
if !project_root.exists() || !project_root.is_dir() {
return UndoResult {
ok: false,
session_id: String::new(),
restored: vec![],
error: Some("path_invalid".into()),
error_code: Some("PATH_INVALID".into()),
};
}
let data_dir = match app_data_dir(&app) {
Ok(d) => d,
Err(e) => {
return UndoResult {
ok: false,
session_id: String::new(),
restored: vec![],
error: Some(e),
error_code: Some("APP_DATA_DIR".into()),
};
}
};
let last_path = data_dir.join("history").join("last_session.txt");
let session_id = match fs::read_to_string(&last_path) {
Ok(s) => s.trim().to_string(),
Err(_) => {
return UndoResult {
ok: false,
session_id: String::new(),
restored: vec![],
error: Some("no_undo_available".into()),
error_code: Some("UNDO_NOT_AVAILABLE".into()),
};
}
};
let session_dir = data_dir.join("history").join(&session_id);
let _ = window.emit(PROGRESS_EVENT, "Откатываю изменения…");
match revert_snapshot(&session_dir, &project_root) {
Ok(restored) => UndoResult {
ok: true,
session_id,
restored,
error: None,
error_code: None,
},
Err(e) => UndoResult {
ok: false,
session_id,
restored: vec![],
error: Some(e),
error_code: Some("UNDO_FAILED".into()),
},
}
}

View File

@ -0,0 +1,31 @@
mod commands;
mod types;
use commands::{analyze_project, apply_actions, get_app_info, preview_actions, undo_last};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
analyze_project,
preview_actions,
apply_actions,
undo_last,
get_app_info,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

View File

@ -0,0 +1,129 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
CreateFile,
UpdateFile,
DeleteFile,
CreateDir,
DeleteDir,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub id: String,
pub title: String,
pub description: String,
pub kind: ActionKind,
pub path: String,
pub content: Option<String>, // для create/update
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyResult {
pub ok: bool,
pub session_id: String,
pub applied: Vec<String>,
pub skipped: Vec<String>,
pub error: Option<String>,
pub error_code: Option<String>,
pub undo_available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoResult {
pub ok: bool,
pub session_id: String,
pub restored: Vec<String>,
pub error: Option<String>,
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffItem {
pub path: String,
pub kind: String, // "create" | "update" | "delete" | "mkdir" | "rmdir"
pub before: Option<String>,
pub after: Option<String>,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewResult {
pub ok: bool,
pub diffs: Vec<DiffItem>,
pub error: Option<String>,
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectContext {
pub stack: Vec<String>,
pub domain: String,
pub maturity: String, // Prototype | MVP | Production-like
pub complexity: String, // Low | Medium | High
pub risk_level: String, // Low | Medium | High
}
#[derive(Debug, Clone, Serialize)]
pub struct LlmContext {
pub concise_summary: String,
pub key_risks: Vec<String>,
pub top_recommendations: Vec<String>,
pub signals: Vec<ProjectSignal>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReportStats {
pub file_count: u64,
pub dir_count: u64,
pub total_size_bytes: u64,
pub top_extensions: Vec<(String, u64)>,
pub max_depth: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub severity: String, // info|warn|high
pub title: String,
pub details: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Recommendation {
pub title: String,
pub details: String,
pub priority: String, // high|medium|low
pub effort: String, // low|medium|high
pub impact: String, // low|medium|high
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectStructure {
pub project_type: String,
pub architecture: String,
pub structure_notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectSignal {
pub category: String, // security|quality|structure
pub level: String, // info|warn|high
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalyzeReport {
pub path: String,
pub narrative: String,
pub stats: ReportStats,
pub structure: ProjectStructure,
pub signals: Vec<ProjectSignal>,
pub findings: Vec<Finding>,
pub recommendations: Vec<Recommendation>,
pub actions: Vec<Action>,
pub project_context: ProjectContext,
pub report_md: String,
pub llm_context: LlmContext,
}

View File

@ -0,0 +1,356 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
},
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
},
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
},
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
},
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri App PAPA-YU",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"beforeDevCommand": "cd ../ui && npm run dev -- --port 5173",
"beforeBuildCommand": "cd ../ui && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../ui/dist"
}
},
"app": {
"windows": [
{
"title": "Tauri PAPA-YU",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
"bundle": {
"active": true,

24
desktop/ui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
desktop/ui/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
desktop/ui/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAPA YU</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4297
desktop/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
desktop/ui/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"animejs": "^4.3.5",
"lucide-react": "^0.460.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
desktop/ui/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

60
desktop/ui/src/App.tsx Normal file
View File

@ -0,0 +1,60 @@
import { HashRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import { Dashboard } from './pages/Dashboard';
import { Tasks } from './pages/Tasks';
import { Reglamenty } from './pages/Reglamenty';
import { TMCZakupki } from './pages/TMCZakupki';
import { Finances } from './pages/Finances';
import { Personnel } from './pages/Personnel';
import { PolicyEngine } from './pages/PolicyEngine';
import { AuditLogger } from './pages/AuditLogger';
import { SecretsGuard } from './pages/SecretsGuard';
import { Updates } from './pages/Updates';
import { Diagnostics } from './pages/Diagnostics';
import { Layout } from './components/layout/Layout';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ErrorDisplay } from './components/ErrorDisplay';
import { NotFound } from './pages/NotFound';
import { ROUTES } from './config/routes';
import { useAppStore } from './store/app-store';
function RouteTracker() {
const location = useLocation();
useEffect(() => {
try {
useAppStore.getState().setCurrentRoute(location.pathname);
} catch (_) {}
}, [location.pathname]);
return null;
}
function App() {
return (
<ErrorBoundary>
<HashRouter>
<RouteTracker />
<ErrorDisplay />
<Layout>
<Routes>
<Route path={ROUTES.DASHBOARD.path} element={<Navigate to={ROUTES.TASKS.path} replace />} />
<Route path={ROUTES.TASKS.path} element={<Tasks />} />
<Route path={ROUTES.REGLAMENTY.path} element={<Reglamenty />} />
<Route path={ROUTES.TMC_ZAKUPKI.path} element={<TMCZakupki />} />
<Route path={ROUTES.FINANCES.path} element={<Finances />} />
<Route path={ROUTES.PERSONNEL.path} element={<Personnel />} />
<Route path={ROUTES.CONTROL_PANEL.path} element={<Dashboard />} />
<Route path={ROUTES.CHAT_AGENT.path} element={<Navigate to={ROUTES.TASKS.path} replace />} />
<Route path={ROUTES.POLICY_ENGINE.path} element={<PolicyEngine />} />
<Route path={ROUTES.AUDIT_LOGGER.path} element={<AuditLogger />} />
<Route path={ROUTES.SECRETS_GUARD.path} element={<SecretsGuard />} />
<Route path={ROUTES.UPDATES.path} element={<Updates />} />
<Route path={ROUTES.DIAGNOSTICS.path} element={<Diagnostics />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>
</HashRouter>
</ErrorBoundary>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,63 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
errorInfo: null,
};
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ error, errorInfo });
console.error('ErrorBoundary:', error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-8">
<div className="max-w-2xl w-full bg-card p-8 rounded-xl border">
<div className="text-center mb-6">
<div className="text-6xl mb-4"></div>
<h1 className="text-3xl font-bold mb-2">Произошла ошибка</h1>
<p className="text-muted-foreground">Приложение столкнулось с неожиданной ошибкой</p>
</div>
{import.meta.env.DEV && (
<div className="mt-6 p-4 bg-muted rounded-lg">
<pre className="text-xs overflow-auto">{this.state.error.toString()}</pre>
</div>
)}
<div className="mt-6 flex gap-4 justify-center">
<button
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Вернуться
</button>
<button onClick={() => window.location.reload()} className="px-6 py-2 border rounded-md hover:bg-muted">
Перезагрузить
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/app-store';
export function ErrorDisplay() {
const error = useAppStore((s) => s.error);
const setError = useAppStore((s) => s.setError);
useEffect(() => {
if (error) {
const t = setTimeout(() => setError(null), 10000);
return () => clearTimeout(t);
}
}, [error, setError]);
if (!error) return null;
return (
<div className="fixed top-4 right-4 z-50 max-w-md animate-fade-in">
<div className="bg-destructive text-destructive-foreground p-4 rounded-lg shadow-lg border border-destructive/50">
<div className="flex items-start gap-3">
<span className="text-xl"></span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Ошибка</h3>
<p className="text-sm">{error}</p>
</div>
<button onClick={() => setError(null)} className="hover:opacity-80" aria-label="Закрыть">
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ROUTES } from '../../config/routes';
import { eventBus, Events } from '../../lib/event-bus';
import { animateLogo, animateStaggerIn } from '../../lib/anime-utils';
import { LayoutDashboard, ListTodo, FileText, Package, Wallet, Users, MessageSquare, Download } from 'lucide-react';
interface LayoutProps {
children: ReactNode;
}
const NAV_ICONS: Record<string, typeof LayoutDashboard> = {
[ROUTES.DASHBOARD.path]: LayoutDashboard,
[ROUTES.TASKS.path]: ListTodo,
[ROUTES.CONTROL_PANEL.path]: LayoutDashboard,
[ROUTES.UPDATES.path]: Download,
[ROUTES.DIAGNOSTICS.path]: LayoutDashboard,
[ROUTES.REGLAMENTY.path]: FileText,
[ROUTES.TMC_ZAKUPKI.path]: Package,
[ROUTES.FINANCES.path]: Wallet,
[ROUTES.PERSONNEL.path]: Users,
[ROUTES.CHAT_AGENT.path]: MessageSquare,
};
async function checkAndInstallUpdate(): Promise<{ ok: boolean; message: string }> {
try {
const { check } = await import('@tauri-apps/plugin-updater');
const { relaunch } = await import('@tauri-apps/plugin-process');
const update = await check();
if (!update) return { ok: true, message: 'Обновлений нет. У вас актуальная версия.' };
await update.downloadAndInstall();
await relaunch();
return { ok: true, message: 'Установка обновления…' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const friendly =
msg && (msg.includes('fetch') || msg.includes('valid') || msg.includes('signature') || msg.includes('network'))
? 'Обновления пока недоступны (сервер или подпись не настроены).'
: msg || 'Ошибка проверки обновлений.';
return { ok: false, message: friendly };
}
}
export function Layout({ children }: LayoutProps) {
const location = useLocation();
const logoRef = useRef<HTMLImageElement>(null);
const navRef = useRef<HTMLDivElement>(null);
const [updateStatus, setUpdateStatus] = useState<string | null>(null);
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const handleCheckUpdate = async () => {
setIsCheckingUpdate(true);
setUpdateStatus(null);
const result = await checkAndInstallUpdate();
setUpdateStatus(result.message);
setIsCheckingUpdate(false);
};
useEffect(() => {
if (logoRef.current) animateLogo(logoRef.current);
}, []);
useEffect(() => {
if (!navRef.current) return;
const links = navRef.current.querySelectorAll('.nav-item-anime');
if (links.length) animateStaggerIn(links, { staggerDelay: 70, duration: 450 });
}, [location.pathname]);
const handleNav = (path: string) => {
try {
eventBus.emit(Events.NAVIGATE, { path });
eventBus.emit(Events.ROUTE_CHANGED, { path });
} catch (_) {}
};
const navItems = [
ROUTES.TASKS,
ROUTES.CONTROL_PANEL,
ROUTES.UPDATES,
ROUTES.DIAGNOSTICS,
].map((r) => ({ path: r.path, name: r.name, icon: NAV_ICONS[r.path] ?? FileText }));
return (
<div className="min-h-screen bg-background">
<nav className="glass-effect border-b sticky top-0 z-50 shadow-sm">
<div className="container mx-auto px-6 md:px-8 py-4 md:py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link
to={ROUTES.TASKS.path}
className="group flex items-center gap-2 transition-all-smooth hover:opacity-90"
aria-label="PAPA YU"
>
<img
ref={logoRef}
src={`${import.meta.env.BASE_URL}logo-papa-yu.png`}
alt="PAPA YU"
className="h-10 md:h-12 w-auto object-contain"
/>
</Link>
<button
type="button"
onClick={handleCheckUpdate}
disabled={isCheckingUpdate}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg border border-primary/50 text-primary text-xs font-medium hover:bg-primary/10 disabled:opacity-50 transition-colors"
title="Проверить обновления"
>
<img src={`${import.meta.env.BASE_URL}logo-papa-yu.png`} alt="" className="h-5 w-5 object-contain" />
<Download className="w-3.5 h-3.5" />
</button>
{updateStatus && (
<span className="text-xs text-muted-foreground max-w-[140px] truncate" title={updateStatus}>
{updateStatus}
</span>
)}
</div>
<div ref={navRef} className="flex flex-wrap items-center gap-1 md:gap-2">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
onClick={() => handleNav(item.path)}
className={`nav-item-anime group flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs md:text-sm font-medium transition-all-smooth ${
isActive ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
<span>{item.name}</span>
</Link>
);
})}
</div>
</div>
</div>
</nav>
<main>{children}</main>
</div>
);
}

View File

@ -0,0 +1,22 @@
export interface RouteConfig {
path: string;
name: string;
component: string;
description: string;
}
export const ROUTES: Record<string, RouteConfig> = {
DASHBOARD: { path: '/', name: 'Панель', component: 'Dashboard', description: 'Главная панель' },
TASKS: { path: '/tasks', name: 'Задачи', component: 'Tasks', description: 'Задачи с вложениями' },
CONTROL_PANEL: { path: '/control-panel', name: 'Панель управления', component: 'Dashboard', description: 'Панель управления' },
REGLAMENTY: { path: '/reglamenty', name: 'Регламенты', component: 'Reglamenty', description: 'АРМАК, ФАА, ЕАСА' },
TMC_ZAKUPKI: { path: '/tmc-zakupki', name: 'ТМЦ и закупки', component: 'TMCZakupki', description: 'ТМЦ и закупки' },
FINANCES: { path: '/finances', name: 'Финансы', component: 'Finances', description: 'Платежи и отчёты' },
PERSONNEL: { path: '/personnel', name: 'Персонал', component: 'Personnel', description: 'Сотрудники и учёт' },
CHAT_AGENT: { path: '/chat', name: 'Чат с агентом', component: 'ChatAgent', description: 'Диалог с ИИ агентом' },
POLICY_ENGINE: { path: '/policies', name: 'Движок политик', component: 'PolicyEngine', description: 'Правила безопасности' },
AUDIT_LOGGER: { path: '/audit', name: 'Журнал аудита', component: 'AuditLogger', description: 'Действия в системе' },
SECRETS_GUARD: { path: '/secrets', name: 'Защита секретов', component: 'SecretsGuard', description: 'Защита от утечек' },
UPDATES: { path: '/updates', name: 'Обновления', component: 'Updates', description: 'Проверка и установка обновлений' },
DIAGNOSTICS: { path: '/diagnostics', name: 'Диагностика', component: 'Diagnostics', description: 'Версии, пути, логи' },
};

77
desktop/ui/src/index.css Normal file
View File

@ -0,0 +1,77 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
}
* { @apply border-border; }
body {
@apply bg-background text-foreground antialiased;
}
}
@layer utilities {
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-fade-in { animation: fadeIn 0.4s ease-out; }
.transition-all-smooth { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.transition-smooth { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
/* Easing в стиле Anime.js: outExpo, плавные переходы */
.ease-out-expo { transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
.glass-effect {
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
}
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px hsl(var(--primary) / 0.25);
}
.shadow-primary-lg { box-shadow: 0 10px 40px 0 hsl(var(--primary) / 0.2); }
.status-badge { @apply inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium; }
.status-active { @apply bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400; }
.status-inactive { @apply bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400; }
.text-balance { text-wrap: balance; }
}

View File

@ -0,0 +1,109 @@
import { invoke } from '@tauri-apps/api/core';
export type ActionKind =
| 'create_file'
| 'update_file'
| 'delete_file'
| 'create_dir'
| 'delete_dir';
export interface Action {
id: string;
title: string;
description: string;
kind: ActionKind;
path: string;
content?: string | null;
}
export interface ApplyResult {
ok: boolean;
session_id: string;
applied: string[];
skipped: string[];
error?: string | null;
error_code?: string | null;
undo_available: boolean;
}
export interface UndoResult {
ok: boolean;
session_id: string;
restored: string[];
error?: string | null;
error_code?: string | null;
}
export type DiffItem = {
path: string;
kind: 'create' | 'update' | 'delete' | 'mkdir' | 'rmdir' | string;
before?: string | null;
after?: string | null;
summary: string;
};
export type PreviewResult = {
ok: boolean;
diffs: DiffItem[];
error?: string | null;
error_code?: string | null;
};
export interface ProjectStructure {
project_type: string;
architecture: string;
structure_notes: string[];
}
export interface ProjectContext {
stack: string[];
domain: string;
maturity: string;
complexity: string;
risk_level: string;
}
export interface LlmContext {
concise_summary: string;
key_risks: string[];
top_recommendations: string[];
signals: ProjectSignal[];
}
export interface ProjectSignal {
category: string;
level: string;
message: string;
}
export interface Recommendation {
title: string;
details: string;
priority: string;
effort: string;
impact: string;
}
export interface AnalyzeReport {
path: string;
narrative: string;
stats: {
file_count: number;
dir_count: number;
total_size_bytes: number;
top_extensions: [string, number][];
max_depth: number;
};
structure: ProjectStructure;
project_context: ProjectContext;
findings: { severity: string; title: string; details: string }[];
recommendations: Recommendation[];
actions?: Action[];
signals: ProjectSignal[];
report_md: string;
llm_context: LlmContext;
}
export async function analyzeProject(path: string): Promise<AnalyzeReport> {
return invoke<AnalyzeReport>('analyze_project', { path });
}

View File

@ -0,0 +1,68 @@
/**
* Утилиты для Anime.js стиль анимаций как на https://animejs.com/documentation/animation/
*/
import { animate, stagger } from 'animejs';
export { stagger };
/** Анимация появления снизу вверх (fade-in-up) для одного элемента */
export function animateFadeInUp(
target: Element | string | NodeListOf<Element>,
options?: { delay?: number; duration?: number }
) {
return animate(target, {
opacity: [0, 1],
translateY: [24, 0],
duration: options?.duration ?? 600,
delay: options?.delay ?? 0,
ease: 'outExpo',
});
}
/** Stagger-анимация для списка элементов (появление снизу) */
export function animateStaggerIn(
target: string | NodeListOf<Element>,
options?: { staggerDelay?: number; duration?: number }
) {
return animate(target, {
opacity: [0, 1],
translateY: [20, 0],
duration: options?.duration ?? 500,
delay: stagger(options?.staggerDelay ?? 60),
ease: 'outExpo',
});
}
/** Мягкое появление (только opacity) */
export function animateFadeIn(
target: Element | string | NodeListOf<Element>,
options?: { delay?: number; duration?: number }
) {
return animate(target, {
opacity: [0, 1],
duration: options?.duration ?? 400,
delay: options?.delay ?? 0,
ease: 'outQuad',
});
}
/** Анимация логотипа/иконки при загрузке */
export function animateLogo(target: Element | string) {
return animate(target, {
opacity: [0, 1],
scale: [0.92, 1],
duration: 700,
ease: 'outExpo',
});
}
/** Карточки панели управления — stagger с лёгким подъёмом */
export function animateCardsStagger(target: string | NodeListOf<Element>) {
return animate(target, {
opacity: [0, 1],
translateY: [32, 0],
duration: 600,
delay: stagger(120, { start: 0 }),
ease: 'outExpo',
});
}

View File

@ -0,0 +1,28 @@
type EventCallback = (data?: unknown) => void | Promise<void>;
class EventBus {
private listeners: Map<string, EventCallback[]> = new Map();
on(event: string, callback: EventCallback): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event)!.push(callback);
return () => {
const cb = this.listeners.get(event);
if (cb) {
const i = cb.indexOf(callback);
if (i > -1) cb.splice(i, 1);
}
};
}
async emit(event: string, data?: unknown): Promise<void> {
const cb = this.listeners.get(event) || [];
await Promise.all(cb.map((fn) => fn(data)));
}
}
export const eventBus = new EventBus();
export const Events = {
NAVIGATE: 'navigate',
ROUTE_CHANGED: 'route_changed',
} as const;

10
desktop/ui/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,170 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, ArrowLeft, CheckCircle2, XCircle, Clock, Filter, Search, Activity, Lock } from 'lucide-react';
interface AuditEvent {
id: string;
event: string;
actor: string;
timestamp: string;
result?: 'success' | 'failure';
metadata?: Record<string, unknown>;
}
export function AuditLogger() {
const navigate = useNavigate();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<{ type?: string; actor?: string }>({});
useEffect(() => {
const t = setTimeout(() => {
setEvents([
{ id: '1', event: 'command_executed', actor: 'command_router', timestamp: new Date().toISOString(), result: 'success', metadata: { tool: 'fs.read', path: './src/App.tsx' } },
{ id: '2', event: 'policy_denial', actor: 'policy_engine', timestamp: new Date(Date.now() - 3600000).toISOString(), result: 'failure', metadata: { reason: 'tool_not_in_allowlist', tool: 'shell.exec' } },
{ id: '3', event: 'secret_detected', actor: 'secrets_guard', timestamp: new Date(Date.now() - 7200000).toISOString(), result: 'success', metadata: { violationCount: 1, type: 'api_key' } },
]);
setLoading(false);
}, 500);
return () => clearTimeout(t);
}, []);
const getEventIcon = (event: string) => {
if (event.includes('command')) return Activity;
if (event.includes('policy')) return XCircle;
if (event.includes('secret')) return Lock;
return FileText;
};
const filtered = events.filter((e) => {
if (filter.type && e.event !== filter.type) return false;
if (filter.actor && e.actor !== filter.actor) return false;
return true;
});
if (loading) {
return (
<div className="p-8 md:p-12">
<div className="animate-pulse space-y-6">
<div className="h-10 bg-muted rounded w-1/3 mb-6" />
<div className="h-48 bg-muted rounded" />
<div className="space-y-3">
{[1, 2, 3].map((i) => <div key={i} className="h-24 bg-muted rounded" />)}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-purple-50/30 dark:to-purple-950/10">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели управления
</button>
<div className="mb-10 md:mb-12 animate-fade-in">
<div className="flex items-center gap-4 mb-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 ring-2 ring-purple-500/20">
<FileText className="w-8 h-8 text-purple-600" />
</div>
<div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">Журнал аудита</h1>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">Просмотр и анализ всех действий в системе</p>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border mb-8 animate-fade-in-up">
<div className="flex items-center gap-3 mb-6">
<Filter className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Фильтры</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Тип события</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<select
className="w-full pl-10 pr-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.type || ''}
onChange={(e) => setFilter({ ...filter, type: e.target.value })}
>
<option value="">Все события</option>
<option value="command_executed">Выполнение команды</option>
<option value="policy_denial">Отклонение политики</option>
<option value="secret_detected">Обнаружение секрета</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Агент</label>
<select
className="w-full px-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.actor || ''}
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
>
<option value="">Все агенты</option>
<option value="command_router">Command Router</option>
<option value="policy_engine">Policy Engine</option>
<option value="secrets_guard">Secrets Guard</option>
</select>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Activity className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">События</h2>
</div>
<span className="text-sm text-muted-foreground">Всего: {filtered.length}</span>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16">
<FileText className="w-8 h-8 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground text-lg">Нет событий для отображения</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((event) => {
const Icon = getEventIcon(event.event);
const isSuccess = event.result === 'success';
return (
<div key={event.id} className="p-5 rounded-xl border-2 bg-card/50 hover:shadow-lg transition-all-smooth">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${isSuccess ? 'bg-green-100 dark:bg-green-900/20' : 'bg-red-100 dark:bg-red-900/20'}`}>
<Icon className={`w-5 h-5 ${isSuccess ? 'text-green-600' : 'text-red-600'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-semibold">{event.event}</span>
<span className={`status-badge ${isSuccess ? 'status-active' : 'status-inactive'}`}>
{isSuccess ? <><CheckCircle2 className="w-4 h-4" /><span>Успех</span></> : <><XCircle className="w-4 h-4" /><span>Ошибка</span></>}
</span>
<span className="px-3 py-1 rounded-full text-xs font-medium bg-muted">{event.actor}</span>
</div>
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span className="font-mono">{new Date(event.timestamp).toLocaleString('ru-RU')}</span>
</div>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<div className="mt-3 p-3 rounded-lg bg-muted/50 border">
<pre className="text-xs font-mono text-muted-foreground break-all">{JSON.stringify(event.metadata, null, 2)}</pre>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,194 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { open } from '@tauri-apps/plugin-dialog';
import { MessageSquare, ArrowLeft, RotateCcw, Trash2, FolderOpen } from 'lucide-react';
import { analyzeProject, type AnalyzeReport } from '../lib/analyze';
type Message =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
| { role: 'assistant'; report: AnalyzeReport; error?: string };
export function ChatAgent() {
const navigate = useNavigate();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const handleClearChat = () => {
setMessages([]);
};
const handleUndo = () => {
if (messages.length > 0) {
setMessages((prev) => prev.slice(0, -1));
}
};
const handleSend = () => {
if (!input.trim()) return;
setMessages((prev) => [...prev, { role: 'user', text: input.trim() }]);
setInput('');
setTimeout(() => {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: 'Ответ ИИ агента будет отображаться здесь. Результаты действий агента подключаются к backend.' },
]);
}, 500);
};
const handlePickFolderAndAnalyze = async () => {
const selected = await open({
directory: true,
multiple: false,
});
if (!selected) return;
const pathStr = selected;
setMessages((prev) => [
...prev,
{ role: 'user', text: `Проанализируй проект: ${pathStr}` },
{ role: 'assistant', text: 'Индексирую файлы…' },
]);
try {
const report = await analyzeProject(pathStr);
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', report };
return next;
});
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', report: {} as AnalyzeReport, error: errMsg };
return next;
});
}
};
return (
<div className="min-h-screen flex flex-col bg-background">
<div className="p-4 border-b flex items-center justify-between flex-wrap gap-2">
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" />
Назад
</button>
<div className="flex items-center gap-2">
<button
onClick={handlePickFolderAndAnalyze}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/50 text-primary text-sm font-medium hover:bg-primary/10"
>
<FolderOpen className="w-4 h-4" />
Выбрать папку
</button>
<button
onClick={handleClearChat}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted"
>
<Trash2 className="w-4 h-4" />
Очистка чата
</button>
<button
onClick={handleUndo}
disabled={messages.length === 0}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="w-4 h-4" />
Откат
</button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-2xl mx-auto">
{messages.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Диалог с ИИ агентом. Результаты действий отображаются здесь.</p>
<p className="text-sm mt-2">Нажмите «Выбрать папку» для анализа проекта или введите сообщение ниже.</p>
</div>
) : (
<div className="space-y-4">
{messages.map((m, i) => (
<div
key={i}
className={`p-4 rounded-xl ${m.role === 'user' ? 'bg-primary/10 ml-8' : 'bg-muted/50 mr-8'}`}
>
<div className="text-xs font-medium text-muted-foreground mb-1">
{m.role === 'user' ? 'Вы' : 'Агент'}
</div>
{'text' in m && <div className="text-sm">{m.text}</div>}
{'report' in m && m.report && (
<ReportBlock report={m.report} error={(m as Message & { error?: string }).error} />
)}
</div>
))}
</div>
)}
</div>
</div>
<div className="p-4 border-t">
<div className="max-w-2xl mx-auto flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Сообщение или путь к папке для анализа..."
className="flex-1 px-4 py-2.5 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
onClick={handleSend}
className="px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
>
Отправить
</button>
</div>
</div>
</div>
);
}
function ReportBlock({ report, error }: { report: AnalyzeReport; error?: string }) {
if (error) {
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
}
const r = report as AnalyzeReport;
return (
<div className="text-sm space-y-3">
<p className="font-medium">{r.narrative || r.path}</p>
{r.findings?.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Находки</p>
<ul className="list-disc list-inside space-y-0.5">
{r.findings.slice(0, 10).map((f, i) => (
<li key={i}>
<span className={f.severity === 'high' ? 'text-destructive' : ''}>{f.title}</span>
{f.details && `${f.details}`}
</li>
))}
</ul>
</div>
)}
{r.recommendations?.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Рекомендации</p>
<ul className="list-disc list-inside space-y-0.5">
{r.recommendations.slice(0, 10).map((rec, i) => (
<li key={i}>
<span className="font-medium">{rec.title}</span>
{rec.details && `${rec.details}`}
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,130 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '../config/routes';
import { eventBus, Events } from '../lib/event-bus';
import { useAppStore } from '../store/app-store';
import { animateCardsStagger, animateFadeInUp } from '../lib/anime-utils';
import { Shield, FileText, Lock, CheckCircle2, ArrowRight, Sparkles } from 'lucide-react';
export function Dashboard() {
const headerRef = useRef<HTMLDivElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const systemStatus = useAppStore((s) => s.systemStatus);
const addAuditEvent = useAppStore((s) => s.addAuditEvent);
const handleCardClick = (path: string) => {
try {
eventBus.emit(Events.NAVIGATE, { path });
addAuditEvent({ id: `nav-${Date.now()}`, event: 'navigation', timestamp: new Date().toISOString(), actor: 'user' });
} catch (_) {}
navigate(path);
};
useEffect(() => {
if (headerRef.current) animateFadeInUp(headerRef.current, { duration: 600 });
}, []);
useEffect(() => {
if (cardsRef.current) animateCardsStagger(cardsRef.current.querySelectorAll('.card-item-anime'));
}, []);
const cards = [
{
path: ROUTES.POLICY_ENGINE.path,
title: 'Движок политик',
description: systemStatus.policyEngine === 'active' ? 'Активен и применяет политики безопасности' : 'Неактивен',
icon: Shield,
status: systemStatus.policyEngine,
gradient: 'from-blue-500/10 to-blue-600/5',
iconColor: 'text-blue-600',
borderColor: 'border-blue-200/50',
},
{
path: ROUTES.AUDIT_LOGGER.path,
title: 'Журнал аудита',
description: systemStatus.auditLogger === 'active' ? 'Все действия логируются' : 'Логирование неактивно',
icon: FileText,
status: systemStatus.auditLogger,
gradient: 'from-purple-500/10 to-purple-600/5',
iconColor: 'text-purple-600',
borderColor: 'border-purple-200/50',
},
{
path: ROUTES.SECRETS_GUARD.path,
title: 'Защита секретов',
description: systemStatus.secretsGuard === 'active' ? 'Мониторинг утечек секретов' : 'Мониторинг неактивен',
icon: Lock,
status: systemStatus.secretsGuard,
gradient: 'from-emerald-500/10 to-emerald-600/5',
iconColor: 'text-emerald-600',
borderColor: 'border-emerald-200/50',
},
];
return (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-muted/20">
<div ref={headerRef} className="mb-12 md:mb-16">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-balance">Панель управления</h1>
</div>
<p className="text-lg md:text-xl text-muted-foreground font-light max-w-2xl">
Управление системой безопасности и политиками
</p>
</div>
<div ref={cardsRef} className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{cards.map((card) => {
const Icon = card.icon;
const isActive = card.status === 'active';
return (
<div
key={card.path}
role="button"
tabIndex={0}
onClick={() => handleCardClick(card.path)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleCardClick(card.path)}
className={`card-item-anime group relative bg-card/80 backdrop-blur-sm p-8 rounded-2xl border-2 cursor-pointer hover-lift transition-all-smooth ${card.borderColor} hover:border-primary/50 hover:shadow-primary-lg focus:outline-none focus:ring-2 focus:ring-primary`}
>
<div className="relative z-10">
<div className="flex items-start justify-between mb-6">
<div className={`p-3 rounded-xl bg-gradient-to-br ${card.gradient} ${isActive ? 'ring-2 ring-primary/20' : ''}`}>
<Icon className={`w-6 h-6 ${card.iconColor}`} />
</div>
{isActive && (
<div className="status-badge status-active">
<CheckCircle2 className="w-4 h-4" />
<span>Активен</span>
</div>
)}
</div>
<h2 className="text-2xl md:text-3xl font-bold mb-3 tracking-tight group-hover:text-primary transition-colors">
{card.title}
</h2>
<p className="text-base text-muted-foreground mb-6 min-h-[3rem]">{card.description}</p>
<div className="flex items-center gap-2 text-primary font-semibold group-hover:gap-3 transition-all">
<span>Открыть</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
);
})}
</div>
<div className="mt-12 md:mt-16 animate-fade-in-up" style={{ animationDelay: '0.4s', animationFillMode: 'both' }}>
<div className="bg-card/50 backdrop-blur-sm border rounded-xl p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Система безопасности</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Все модули работают в режиме реального времени. Изменения применяются немедленно и логируются в журнале аудита.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { Copy, Check, Download } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
interface AppInfo {
version: string;
app_data_dir: string | null;
app_config_dir: string | null;
}
export function Diagnostics() {
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
const [tauriVersion, setTauriVersion] = useState<string>('—');
const [copied, setCopied] = useState(false);
useEffect(() => {
(async () => {
try {
const info = await invoke<AppInfo>('get_app_info');
setAppInfo(info);
} catch (_) {
setAppInfo(null);
}
try {
const v = await getVersion();
setTauriVersion(v);
} catch (_) {}
})();
}, []);
const buildDiagnosticsText = () => {
const lines = [
`PAPA YU Diagnostics — ${new Date().toISOString()}`,
'',
'Версии:',
` App (package): ${appInfo?.version ?? '—'}`,
` Tauri (getVersion): ${tauriVersion}`,
'',
'Пути (системные директории Tauri/OS):',
` app_data_dir: ${appInfo?.app_data_dir ?? '—'}`,
` app_config_dir: ${appInfo?.app_config_dir ?? '—'}`,
'',
'Updater:',
' endpoint: https://github.com/yrippert-maker/papayu/releases/latest/download/latest.json',
' подпись: требуется (pubkey в tauri.conf.json)',
'',
];
return lines.join('\n');
};
const handleCopy = async () => {
const text = buildDiagnosticsText();
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleExport = () => {
const text = buildDiagnosticsText();
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `papayu-diagnostics-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-xl font-semibold text-foreground mb-6">Диагностика</h1>
<div className="space-y-6 rounded-lg border bg-card p-4">
<section>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Версии</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-muted-foreground">Приложение:</dt>
<dd className="font-mono">{appInfo?.version ?? tauriVersion ?? '—'}</dd>
<dt className="text-muted-foreground">Tauri:</dt>
<dd className="font-mono">{tauriVersion}</dd>
</dl>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Пути данных</h2>
<p className="text-xs text-muted-foreground mb-1">Используются системные директории (не зависят от $HOME):</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm font-mono break-all">
<dt className="text-muted-foreground">app_data_dir:</dt>
<dd>{appInfo?.app_data_dir ?? '—'}</dd>
<dt className="text-muted-foreground">app_config_dir:</dt>
<dd>{appInfo?.app_config_dir ?? '—'}</dd>
</dl>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Состояние обновлений</h2>
<p className="text-sm text-muted-foreground">
Endpoint: <code className="bg-muted px-1 rounded text-xs">/releases/latest/download/latest.json</code>
</p>
<p className="text-xs text-muted-foreground mt-1">
Подпись обязательна; pubkey задаётся в tauri.conf.json. Если ключ не настроен проверка обновлений вернёт ошибку.
</p>
</section>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border font-medium text-sm hover:bg-muted"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Скопировано' : 'Скопировать отчёт'}
</button>
<button
type="button"
onClick={handleExport}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border font-medium text-sm hover:bg-muted"
>
<Download className="w-4 h-4" />
Экспортировать логи
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom';
import { FolderOpen, ArrowLeft } from 'lucide-react';
export function Documents() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<FolderOpen className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Документы</h1>
<p className="text-lg text-muted-foreground mt-2">Все документы компании Mura Menasa</p>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6">
<p className="text-muted-foreground">Раздел документов компании.</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom';
import { Wallet, ArrowLeft } from 'lucide-react';
export function Finances() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<Wallet className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Финансы</h1>
<p className="text-lg text-muted-foreground mt-2">Диалог для ввода платежей (текст, скриншот, PDF), ИИ распознавание, таблица, выгрузка в PDF/Excel за период</p>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6">
<p className="text-muted-foreground">Модуль финансов: ввод данных о платежах, распознавание ИИ, отчёты за выбранный период.</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { useNavigate } from 'react-router-dom';
export function NotFound() {
const navigate = useNavigate();
return (
<div className="min-h-screen flex items-center justify-center bg-background p-8">
<div className="max-w-2xl w-full text-center">
<div className="text-6xl mb-6">404</div>
<h1 className="text-4xl font-bold mb-4">Страница не найдена</h1>
<p className="text-lg text-muted-foreground mb-8">Запрашиваемая страница не существует или была перемещена</p>
<button
onClick={() => navigate('/')}
className="px-6 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth"
>
Вернуться на главную
</button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More