chore: add project files for CI and release
19
.gitignore
vendored
Normal 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
@ -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
@ -0,0 +1,3 @@
|
||||
# desktop-core (архив)
|
||||
|
||||
Каталог оставлен для совместимости. Содержимое `tools/project-auditor/` не используется основным приложением PAPA YU. В будущем здесь могут быть общие утилиты для desktop-сборки.
|
||||
0
desktop-core/tools/project-auditor/index.ts
Normal file
4
desktop/src-tauri/.gitignore
vendored
Normal 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
30
desktop/src-tauri/Cargo.toml
Normal 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"
|
||||
3
desktop/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
desktop/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
@ -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>
|
||||
BIN
desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 34 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
desktop/src-tauri/icons/icon.icns
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
desktop/src-tauri/icons/icon_source.png
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
745
desktop/src-tauri/src/commands/analyze_project.rs
Normal 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(())
|
||||
}
|
||||
249
desktop/src-tauri/src/commands/apply_actions.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
21
desktop/src-tauri/src/commands/get_app_info.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
11
desktop/src-tauri/src/commands/mod.rs
Normal 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;
|
||||
138
desktop/src-tauri/src/commands/preview_actions.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
124
desktop/src-tauri/src/commands/undo_last.rs
Normal 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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
31
desktop/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
6
desktop/src-tauri/src/main.rs
Normal 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();
|
||||
}
|
||||
129
desktop/src-tauri/src/types.rs
Normal 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,
|
||||
}
|
||||
356
desktop/src-tauri/tauri.conf.json.save
Normal 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
@ -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
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
desktop/ui/eslint.config.js
Normal 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
@ -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
42
desktop/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
desktop/ui/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
desktop/ui/public/logo-papa-yu.png
Normal file
|
After Width: | Height: | Size: 491 KiB |
1
desktop/ui/public/vite.svg
Normal 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
@ -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
@ -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;
|
||||
1
desktop/ui/src/assets/react.svg
Normal 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 |
63
desktop/ui/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
33
desktop/ui/src/components/ErrorDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
desktop/ui/src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
desktop/ui/src/config/routes.ts
Normal 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
@ -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; }
|
||||
}
|
||||
109
desktop/ui/src/lib/analyze.ts
Normal 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 });
|
||||
}
|
||||
68
desktop/ui/src/lib/anime-utils.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
28
desktop/ui/src/lib/event-bus.ts
Normal 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
@ -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>
|
||||
);
|
||||
170
desktop/ui/src/pages/AuditLogger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
desktop/ui/src/pages/ChatAgent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
desktop/ui/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
desktop/ui/src/pages/Diagnostics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
desktop/ui/src/pages/Documents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
desktop/ui/src/pages/Finances.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
desktop/ui/src/pages/NotFound.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||