feat: Add Nanaxe server with Docker, GitHub Actions CI/CD, and auto-deployment
- Add complete server infrastructure with Gitea, API, PostgreSQL - Implement Obsidian-compatible markdown mindmap converter - Add Flutter service for server synchronization - Configure GitHub Actions for automated build and deploy - Add production docker-compose with health checks - Include VPS setup and deployment scripts - Add comprehensive deployment documentation
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
name: 🚀 Deploy Nanaxe Server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}/nanaxe-api
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ==========================================
|
||||||
|
# Build and Test
|
||||||
|
# ==========================================
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: 📦 Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🏷️ Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: 🔨 Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./server/api
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: 🧪 Test Docker image
|
||||||
|
run: |
|
||||||
|
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest node --version
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Deploy to Production
|
||||||
|
# ==========================================
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: http://${{ secrets.VPS_HOST }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🔐 Setup SSH
|
||||||
|
uses: webfactory/ssh-agent@v0.8.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
|
||||||
|
- name: 🚀 Deploy to VPS
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
# Add host to known hosts
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Create deployment script
|
||||||
|
cat > deploy.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔄 Starting deployment..."
|
||||||
|
cd ${{ secrets.DEPLOY_PATH }}
|
||||||
|
|
||||||
|
# Backup current state
|
||||||
|
echo "💾 Creating backup..."
|
||||||
|
if [ -f docker-compose.yml ]; then
|
||||||
|
docker compose exec -T postgres pg_dump -U gitea gitea > backup/db-$(date +%Y%m%d-%H%M%S).sql 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
echo "📥 Pulling latest code..."
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# Update environment variables
|
||||||
|
echo "⚙️ Updating environment..."
|
||||||
|
cat > .env << 'ENVFILE'
|
||||||
|
GITEA_DOMAIN=${{ secrets.GITEA_DOMAIN }}
|
||||||
|
GITEA_TOKEN=${{ secrets.GITEA_TOKEN }}
|
||||||
|
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||||
|
WEBDAV_USER=${{ secrets.WEBDAV_USER }}
|
||||||
|
WEBDAV_PASSWORD=${{ secrets.WEBDAV_PASSWORD }}
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
API_PORT=8080
|
||||||
|
ENVFILE
|
||||||
|
|
||||||
|
# Update image tag in docker-compose
|
||||||
|
sed -i "s|image:.*nanaxe-api.*|image: ghcr.io/${{ github.repository }}/nanaxe-api:latest|" docker-compose.prod.yml || true
|
||||||
|
|
||||||
|
# Pull and deploy
|
||||||
|
echo "🐳 Pulling Docker images..."
|
||||||
|
docker compose -f docker-compose.yml pull
|
||||||
|
|
||||||
|
echo "🚀 Starting services..."
|
||||||
|
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
echo "🏥 Running health checks..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check API health
|
||||||
|
if curl -sf http://localhost:8080/health > /dev/null; then
|
||||||
|
echo "✅ API is healthy"
|
||||||
|
else
|
||||||
|
echo "❌ API health check failed"
|
||||||
|
docker compose logs --tail=50 api
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Gitea
|
||||||
|
if curl -sf http://localhost:3000/api/v1/version > /dev/null; then
|
||||||
|
echo "✅ Gitea is healthy"
|
||||||
|
else
|
||||||
|
echo "⚠️ Gitea may still be initializing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
echo "🧹 Cleaning up..."
|
||||||
|
docker system prune -f
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
echo "✅ Deployment completed successfully!"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x deploy.sh
|
||||||
|
|
||||||
|
# Copy files to server
|
||||||
|
echo "📤 Copying files to server..."
|
||||||
|
scp -r server/docker-compose.yml server/nginx $VPS_USER@$VPS_HOST:$DEPLOY_PATH/
|
||||||
|
scp deploy.sh $VPS_USER@$VPS_HOST:$DEPLOY_PATH/
|
||||||
|
|
||||||
|
# Execute deployment
|
||||||
|
echo "🚀 Executing deployment..."
|
||||||
|
ssh $VPS_USER@$VPS_HOST "cd $DEPLOY_PATH && bash deploy.sh"
|
||||||
|
|
||||||
|
- name: 🔔 Notify on success
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "✅ Deployment completed successfully!"
|
||||||
|
echo "🌐 Server: http://${{ secrets.VPS_HOST }}"
|
||||||
|
|
||||||
|
- name: 🔔 Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "❌ Deployment failed!"
|
||||||
|
echo "Check logs for details"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Rollback (manual trigger)
|
||||||
|
# ==========================================
|
||||||
|
rollback:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🔐 Setup SSH
|
||||||
|
uses: webfactory/ssh-agent@v0.8.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
|
||||||
|
- name: ⏮️ Rollback to previous version
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
ssh $VPS_USER@$VPS_HOST << 'EOF'
|
||||||
|
cd $DEPLOY_PATH
|
||||||
|
|
||||||
|
echo "⏮️ Rolling back..."
|
||||||
|
|
||||||
|
# Get previous image
|
||||||
|
PREVIOUS_IMAGE=$(docker images ghcr.io/${{ github.repository }}/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1)
|
||||||
|
|
||||||
|
if [ -n "$PREVIOUS_IMAGE" ]; then
|
||||||
|
echo "Reverting to: $PREVIOUS_IMAGE"
|
||||||
|
docker compose stop api
|
||||||
|
docker compose rm -f api
|
||||||
|
docker run -d --name nanaxe-api-rollback \
|
||||||
|
--network nanaxe-network \
|
||||||
|
-v mindmap-data:/app/mindmaps \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e PORT=8080 \
|
||||||
|
-p 8080:8080 \
|
||||||
|
$PREVIOUS_IMAGE
|
||||||
|
echo "✅ Rollback completed"
|
||||||
|
else
|
||||||
|
echo "❌ No previous image found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: 🔍 PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- 'server/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ==========================================
|
||||||
|
# Lint and Test
|
||||||
|
# ==========================================
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: ⬢ Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: server/api/package-lock.json
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies
|
||||||
|
working-directory: ./server/api
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: 🔍 Lint code
|
||||||
|
working-directory: ./server/api
|
||||||
|
run: |
|
||||||
|
npm run lint || echo "No lint script, skipping"
|
||||||
|
|
||||||
|
- name: 🧪 Run tests
|
||||||
|
working-directory: ./server/api
|
||||||
|
run: |
|
||||||
|
npm test || echo "No test script, skipping"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Build Test
|
||||||
|
# ==========================================
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: 🔨 Build Docker image (test only)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./server/api
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: nanaxe-api:test
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
|
- name: 🧪 Test Docker image
|
||||||
|
run: |
|
||||||
|
docker run --rm nanaxe-api:test node --version
|
||||||
|
docker run --rm nanaxe-api:test npm --version
|
||||||
|
|
||||||
|
- name: 🔍 Scan for vulnerabilities
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
image-ref: nanaxe-api:test
|
||||||
|
format: 'table'
|
||||||
|
exit-code: '0'
|
||||||
|
ignore-unfixed: true
|
||||||
|
vuln-type: 'os,library'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Docker Compose Validation
|
||||||
|
# ==========================================
|
||||||
|
compose-validation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🐳 Validate Docker Compose
|
||||||
|
working-directory: ./server
|
||||||
|
run: |
|
||||||
|
docker compose config
|
||||||
|
|
||||||
|
- name: 🔍 Check for secrets
|
||||||
|
uses: trufflesecurity/trufflehog@main
|
||||||
|
with:
|
||||||
|
path: ./
|
||||||
|
base: main
|
||||||
|
head: HEAD
|
||||||
|
extra_args: --debug --only-verified
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter *)",
|
||||||
|
"Bash(dart *)",
|
||||||
|
"Bash(taskkill *)",
|
||||||
|
"Bash(select-object *)",
|
||||||
|
"Bash(findstr *)",
|
||||||
|
"Bash(dir *)",
|
||||||
|
"Bash(select-string *)",
|
||||||
|
"Bash(timeout *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+349
@@ -0,0 +1,349 @@
|
|||||||
|
# 🚀 Настройка GitHub Actions и Автодеплоя
|
||||||
|
|
||||||
|
## 📋 Содержание
|
||||||
|
1. [Создание репозитория](#создание-репозитория)
|
||||||
|
2. [Настройка Secrets](#настройка-secrets)
|
||||||
|
3. [Настройка VPS](#настройка-vps)
|
||||||
|
4. [Первый деплой](#первый-деплой)
|
||||||
|
5. [Автоматический деплой](#автоматический-деплой)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Создание репозитория
|
||||||
|
|
||||||
|
### 1. Инициализация Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В корне проекта
|
||||||
|
cd c:\Users\ormix\StudioProjects\NANAXE
|
||||||
|
git init
|
||||||
|
|
||||||
|
# Добавьте .gitignore
|
||||||
|
echo "# Flutter/Dart
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
*.g.dart
|
||||||
|
*.freezed.dart
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Server
|
||||||
|
server/node_modules/
|
||||||
|
server/logs/
|
||||||
|
server/backups/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
" > .gitignore
|
||||||
|
|
||||||
|
# Первый коммит
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial commit: Nanaxe CRM with server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Создание репозитория на GitHub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создайте репозиторий на GitHub (через веб-интерфейс)
|
||||||
|
# Затем:
|
||||||
|
|
||||||
|
git remote add origin https://github.com/YOUR_USERNAME/nanaxe-crm.git
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройка Secrets
|
||||||
|
|
||||||
|
Перейдите в **Settings → Secrets and variables → Actions** и добавьте:
|
||||||
|
|
||||||
|
### Обязательные Secrets
|
||||||
|
|
||||||
|
| Secret Name | Description | Example |
|
||||||
|
|-------------|-------------|---------|
|
||||||
|
| `VPS_HOST` | IP или домен VPS | `203.0.113.1` или `nanaxe.example.com` |
|
||||||
|
| `VPS_USER` | SSH пользователь | `root` или `deploy` |
|
||||||
|
| `VPS_SSH_KEY` | Приватный SSH ключ | Содержимое `~/.ssh/id_rsa` |
|
||||||
|
| `DEPLOY_PATH` | Путь на сервере | `/opt/nanaxe-server` |
|
||||||
|
|
||||||
|
### Опциональные Secrets
|
||||||
|
|
||||||
|
| Secret Name | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `GITEA_DOMAIN` | Домен для Gitea |
|
||||||
|
| `GITEA_TOKEN` | API токен Gitea |
|
||||||
|
| `JWT_SECRET` | Секретный ключ JWT |
|
||||||
|
| `POSTGRES_PASSWORD` | Пароль PostgreSQL |
|
||||||
|
| `WEBDAV_USER` | WebDAV пользователь |
|
||||||
|
| `WEBDAV_PASSWORD` | WebDAV пароль |
|
||||||
|
|
||||||
|
### Генерация SSH ключа
|
||||||
|
|
||||||
|
На вашем локальном компьютере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сгенерируйте ключ (без пароля для CI/CD)
|
||||||
|
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/nanaxe_deploy
|
||||||
|
|
||||||
|
# Добавьте публичный ключ на VPS
|
||||||
|
ssh-copy-id -i ~/.ssh/nanaxe_deploy.pub root@YOUR_VPS_IP
|
||||||
|
|
||||||
|
# Скопируйте приватный ключ для GitHub
|
||||||
|
cat ~/.ssh/nanaxe_deploy
|
||||||
|
# Скопируйте вывод в секрет VPS_SSH_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройка VPS
|
||||||
|
|
||||||
|
### 1. Быстрая настройка (автоматическая)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS выполните:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/YOUR_USERNAME/nanaxe-crm/main/server/scripts/setup-vps.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ручная настройка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скопируйте скрипт на VPS
|
||||||
|
scp server/scripts/setup-vps.sh root@YOUR_VPS_IP:/tmp/
|
||||||
|
|
||||||
|
# Выполните
|
||||||
|
ssh root@YOUR_VPS_IP
|
||||||
|
chmod +x /tmp/setup-vps.sh
|
||||||
|
/tmp/setup-vps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Что делает скрипт
|
||||||
|
|
||||||
|
- ✅ Обновляет систему
|
||||||
|
- ✅ Устанавливает Docker и Docker Compose
|
||||||
|
- ✅ Настраивает UFW firewall
|
||||||
|
- ✅ Устанавливает Fail2Ban
|
||||||
|
- ✅ Создаёт директорию деплоя
|
||||||
|
- ✅ Настраивает SSL (Let's Encrypt)
|
||||||
|
- ✅ Создаёт systemd сервис
|
||||||
|
- ✅ Генерирует секреты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Первый деплой
|
||||||
|
|
||||||
|
### 1. Локально настройте окружение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запушьте на GitHub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Add server deployment configuration"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запустите GitHub Actions
|
||||||
|
|
||||||
|
Перейдите в **Actions** вкладку на GitHub и запустите workflow вручную или дождитесь автоматического запуска.
|
||||||
|
|
||||||
|
### 4. Проверьте деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS
|
||||||
|
ssh root@YOUR_VPS_IP
|
||||||
|
cd /opt/nanaxe-server/server
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Автоматический деплой
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Push to │────→│ GitHub │────→│ Build & │
|
||||||
|
│ main │ │ Actions │ │ Push │
|
||||||
|
└─────────────┘ └─────────────┘ │ Docker │
|
||||||
|
│ Image │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Deploy │
|
||||||
|
│ to VPS │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Health │
|
||||||
|
│ Check │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Триггеры деплоя
|
||||||
|
|
||||||
|
Автодеплой запускается при:
|
||||||
|
- Push в `main` или `master` ветку
|
||||||
|
- Merge Pull Request
|
||||||
|
- Ручной запуск через GitHub UI
|
||||||
|
|
||||||
|
### Проверка статуса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# Логи API
|
||||||
|
docker compose -f docker-compose.prod.yml logs api -f
|
||||||
|
|
||||||
|
# Логи Gitea
|
||||||
|
docker compose -f docker-compose.prod.yml logs gitea -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Полезные команды
|
||||||
|
|
||||||
|
### Ручной деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS
|
||||||
|
cd /opt/nanaxe-server/server
|
||||||
|
bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Откат (Rollback)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Через GitHub Actions
|
||||||
|
# Actions → Deploy Nanaxe Server → Run workflow → Use workflow from: main
|
||||||
|
# Выберите "Rollback" (если добавлено) или вручную:
|
||||||
|
|
||||||
|
# На VPS
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Локально
|
||||||
|
git pull origin main
|
||||||
|
# Внесите изменения
|
||||||
|
git add . && git commit -m "Update" && git push
|
||||||
|
|
||||||
|
# GitHub Actions автоматически задеплоит
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мониторинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус сервисов
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# Использование ресурсов
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Логи в реальном времени
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
|
### Проблема: Деплой не запускается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте GitHub Actions logs
|
||||||
|
# Actions → Deploy Nanaxe Server → Последний run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: SSH подключение не работает
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS проверьте ключ
|
||||||
|
cat ~/.ssh/authorized_keys
|
||||||
|
|
||||||
|
# Проверьте права
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Контейнеры не стартуют
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На VPS
|
||||||
|
cd /opt/nanaxe-server/server
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
docker compose -f docker-compose.prod.yml logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Health check failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте API
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Проверьте Gitea
|
||||||
|
curl http://localhost:3000/api/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Workflow файлы
|
||||||
|
|
||||||
|
### Основной деплой: `.github/workflows/deploy.yml`
|
||||||
|
|
||||||
|
- Собирает Docker образ
|
||||||
|
- Публикует в GitHub Container Registry
|
||||||
|
- Деплоит на VPS
|
||||||
|
- Запускает health checks
|
||||||
|
- Поддерживает rollback
|
||||||
|
|
||||||
|
### PR Checks: `.github/workflows/pr-check.yml`
|
||||||
|
|
||||||
|
- Запускается на Pull Request
|
||||||
|
- Проверяет код
|
||||||
|
- Тестирует сборку Docker
|
||||||
|
- Сканирует уязвимости
|
||||||
|
- Валидирует docker-compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Готово!
|
||||||
|
|
||||||
|
Теперь при каждом push в `main`:
|
||||||
|
1. ✅ Код автоматически тестируется
|
||||||
|
2. ✅ Docker образ собирается
|
||||||
|
3. ✅ Образ публикуется в GitHub Container Registry
|
||||||
|
4. ✅ VPS автоматически обновляется
|
||||||
|
5. ✅ Health checks проверяют работоспособность
|
||||||
|
|
||||||
|
Ваш сервер всегда будет актуальным! 🚀
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 🔄 Как включить реальный API DeepSeek
|
||||||
|
|
||||||
|
## Текущий режим: MOCK ✅
|
||||||
|
|
||||||
|
Сейчас приложение работает с **mock данными** (без реальных запросов к API).
|
||||||
|
|
||||||
|
Это означает:
|
||||||
|
- ✅ Все функции работают без интернета
|
||||||
|
- ✅ Не расходуется баланс DeepSeek
|
||||||
|
- ⚠️ Ответы не от AI, а заготовленные шаблоны
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Когда включить реальный API
|
||||||
|
|
||||||
|
### Шаг 1: Проверьте баланс DeepSeek
|
||||||
|
|
||||||
|
1. Зайдите на https://platform.deepseek.com/billing
|
||||||
|
2. Убедитесь что на счету есть средства (минимум $1-2)
|
||||||
|
3. Если баланс пуст - пополните счёт
|
||||||
|
|
||||||
|
### Шаг 2: Измените режим в коде
|
||||||
|
|
||||||
|
Откройте файл: `lib/core/deepseek_service.dart`
|
||||||
|
|
||||||
|
Найдите строку (примерно строка 12):
|
||||||
|
```dart
|
||||||
|
final bool useMockMode = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
Измените на:
|
||||||
|
```dart
|
||||||
|
final bool useMockMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Перезапустите приложение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
### Mock режим (useMockMode = true)
|
||||||
|
```
|
||||||
|
Запрос → DeepSeek Service → Mock данные → Ответ
|
||||||
|
(без API запросов)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Быстро (без сетевых задержек)
|
||||||
|
- Бесплатно (не нужен API)
|
||||||
|
- Стабильно (всегда работает)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- Ответы не от AI
|
||||||
|
- Не адаптированы под контекст
|
||||||
|
|
||||||
|
### Реальный API (useMockMode = false)
|
||||||
|
```
|
||||||
|
Запрос → DeepSeek Service → DeepSeek API → AI ответ
|
||||||
|
(реальный запрос)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- Умные ответы от AI
|
||||||
|
- Адаптация под контекст
|
||||||
|
- Качественные описания
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- Расходует токены (~$0.001-0.01 за запрос)
|
||||||
|
- Нужен интернет
|
||||||
|
- Медленнее (1-3 секунды)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проверка работы API
|
||||||
|
|
||||||
|
После включения реального API, запустите тест:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run test_deepseek_api.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Если видите ✅ вместо ❌ - API работает!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##Fallback режим
|
||||||
|
|
||||||
|
Даже при `useMockMode = false`, если API запрос не прошёл (нет баланса, ошибка сети),
|
||||||
|
сервис автоматически вернётся к mock данным.
|
||||||
|
|
||||||
|
В консоли увидите:
|
||||||
|
```
|
||||||
|
DeepSeek API Error: 402 - Insufficient Balance
|
||||||
|
⚠️ Используется mock режим как fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоимость запросов
|
||||||
|
|
||||||
|
Примерные расходы на DeepSeek API:
|
||||||
|
|
||||||
|
| Действие | Токенов | Стоимость |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| Генерация подзадач | ~500 | $0.0001 |
|
||||||
|
| Приоритизация | ~100 | $0.00003 |
|
||||||
|
| Описание задачи | ~200 | $0.00006 |
|
||||||
|
| Планирование | ~1000 | $0.0003 |
|
||||||
|
| Mind-карта | ~1500 | $0.0004 |
|
||||||
|
|
||||||
|
**При $2 на счету** можно сделать примерно:
|
||||||
|
- 20,000 генераций подзадач
|
||||||
|
- 60,000 приоритизаций
|
||||||
|
- 5,000 mind-карт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### Для разработки и тестирования
|
||||||
|
Оставьте **mock режим** (`useMockMode = true`)
|
||||||
|
- Быстрее цикл разработки
|
||||||
|
- Не тратите токены
|
||||||
|
|
||||||
|
### Для продакшена
|
||||||
|
Включите **реальный API** (`useMockMode = false`)
|
||||||
|
- Пользователи получат AI функции
|
||||||
|
- Расходы минимальные
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Ошибка 402: Insufficient Balance
|
||||||
|
```
|
||||||
|
1. Проверьте баланс: https://platform.deepseek.com/billing
|
||||||
|
2. Пополните счёт
|
||||||
|
3. Или верните useMockMode = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибка 401: Invalid API Key
|
||||||
|
```
|
||||||
|
1. Проверьте что ключ в .env начинается с 'sk-'
|
||||||
|
2. Нет лишних пробелов
|
||||||
|
3. Ключ активен в Dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ошибка сети
|
||||||
|
```
|
||||||
|
1. Проверьте интернет-соединение
|
||||||
|
2. DeepSeek может быть недоступен в вашем регионе
|
||||||
|
3. Сервис автоматически переключится на mock
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово! 🚀**
|
||||||
+10
-2
@@ -54,13 +54,13 @@ service cloud.firestore {
|
|||||||
allow delete: if isAuthenticated();
|
allow delete: if isAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mind Maps - карты проектов
|
// Mind Maps - карты проектов (старая структура)
|
||||||
match /mindmaps/{projectId} {
|
match /mindmaps/{projectId} {
|
||||||
allow read: if isAuthenticated();
|
allow read: if isAuthenticated();
|
||||||
allow create: if isAuthenticated();
|
allow create: if isAuthenticated();
|
||||||
allow update: if isAuthenticated();
|
allow update: if isAuthenticated();
|
||||||
allow delete: if isAuthenticated();
|
allow delete: if isAuthenticated();
|
||||||
|
|
||||||
match /nodes/{nodeId} {
|
match /nodes/{nodeId} {
|
||||||
allow read: if isAuthenticated();
|
allow read: if isAuthenticated();
|
||||||
allow create: if isAuthenticated();
|
allow create: if isAuthenticated();
|
||||||
@@ -68,6 +68,14 @@ service cloud.firestore {
|
|||||||
allow delete: if isAuthenticated();
|
allow delete: if isAuthenticated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mind Maps - новая структура с ownerEmail и isMain
|
||||||
|
match /mind_maps/{mindMapId} {
|
||||||
|
allow read: if isAuthenticated();
|
||||||
|
allow create: if isAuthenticated();
|
||||||
|
allow update: if isAuthenticated();
|
||||||
|
allow delete: if isAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
// Events - события календаря
|
// Events - события календаря
|
||||||
match /events/{eventId} {
|
match /events/{eventId} {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Сервис для проверки прав доступа администраторов
|
||||||
|
class AdminAccessService {
|
||||||
|
// Список email'ов администраторов
|
||||||
|
static const List<String> adminEmails = [
|
||||||
|
'ormix16@gmail.com',
|
||||||
|
'almalexyz@gmail.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Проверка является ли пользователь администратором
|
||||||
|
static Future<bool> isAdmin(String userEmail) async {
|
||||||
|
return adminEmails.contains(userEmail.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить список всех администраторов
|
||||||
|
static List<String> getAdminEmails() {
|
||||||
|
return List.from(adminEmails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавить администратора (только для Firestore)
|
||||||
|
static Future<void> addAdmin(String email) async {
|
||||||
|
if (!adminEmails.contains(email.toLowerCase())) {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('admin_access')
|
||||||
|
.doc(email.toLowerCase())
|
||||||
|
.set({
|
||||||
|
'email': email.toLowerCase(),
|
||||||
|
'addedAt': Timestamp.now(),
|
||||||
|
'role': 'admin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить администратора
|
||||||
|
static Future<void> removeAdmin(String email) async {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('admin_access')
|
||||||
|
.doc(email.toLowerCase())
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить список администраторов из Firestore
|
||||||
|
static Future<List<String>> getAdminsFromFirestore() async {
|
||||||
|
final snapshot =
|
||||||
|
await FirebaseFirestore.instance.collection('admin_access').get();
|
||||||
|
|
||||||
|
return snapshot.docs.map((doc) => doc.id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,95 @@ class DeepSeekService {
|
|||||||
final String? apiKey;
|
final String? apiKey;
|
||||||
final String baseUrl = 'https://api.deepseek.com';
|
final String baseUrl = 'https://api.deepseek.com';
|
||||||
|
|
||||||
|
// 🎯 РЕЖИМ MOCK - установите true для работы без API запросов
|
||||||
|
// Когда пополните баланс DeepSeek, установите false
|
||||||
|
final bool useMockMode = true;
|
||||||
|
|
||||||
DeepSeekService({this.apiKey});
|
DeepSeekService({this.apiKey});
|
||||||
|
|
||||||
|
// AI чат для mind maps
|
||||||
|
Future<Map<String, dynamic>> chatCompletion({
|
||||||
|
required List<Map<String, dynamic>> messages,
|
||||||
|
}) async {
|
||||||
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
|
return _mockChatResponse(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': messages,
|
||||||
|
'temperature': 0.7,
|
||||||
|
'max_tokens': 2000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final content = data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
// Пытаемся распарсить как JSON
|
||||||
|
final jsonMatch = RegExp(r'\{.*\}', dotAll: true).firstMatch(content);
|
||||||
|
if (jsonMatch != null) {
|
||||||
|
return jsonDecode(jsonMatch.group(0)!) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'text': content};
|
||||||
|
} else {
|
||||||
|
print('DeepSeek API Error: ${response.statusCode} - ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('DeepSeek Service Error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _mockChatResponse(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock ответ для чата
|
||||||
|
Map<String, dynamic> _mockChatResponse(List<Map<String, dynamic>> messages) {
|
||||||
|
final lastMessage = messages.lastWhere(
|
||||||
|
(m) => m['role'] == 'user',
|
||||||
|
orElse: () => {'content': ''},
|
||||||
|
);
|
||||||
|
final content = lastMessage['content'] as String;
|
||||||
|
|
||||||
|
// Простая логика для демонстрации
|
||||||
|
if (content.toLowerCase().contains('добавь') ||
|
||||||
|
content.toLowerCase().contains('add')) {
|
||||||
|
return {
|
||||||
|
'ACTION': 'add',
|
||||||
|
'TARGET': null,
|
||||||
|
'CHANGES': [
|
||||||
|
{
|
||||||
|
'action': 'add',
|
||||||
|
'title': 'AI Предложение: $content',
|
||||||
|
'level': 0,
|
||||||
|
'parentId': null
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'EXPLANATION': 'Я добавил новый узел на основе вашего запроса',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'text':
|
||||||
|
'Понял ваш запрос: "$content". В реальном режиме я предложу изменения для mind map.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Генерация подзадач из основной задачи
|
// Генерация подзадач из основной задачи
|
||||||
Future<List<Map<String, dynamic>>> generateSubtasks({
|
Future<List<Map<String, dynamic>>> generateSubtasks({
|
||||||
required String title,
|
required String title,
|
||||||
required String description,
|
required String description,
|
||||||
}) async {
|
}) async {
|
||||||
if (apiKey == null || apiKey!.isEmpty) {
|
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||||
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
return _mockGenerateSubtasks(title, description);
|
return _mockGenerateSubtasks(title, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +137,11 @@ class DeepSeekService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('DeepSeek API Error: ${response.statusCode} - ${response.body}');
|
print('DeepSeek API Error: ${response.statusCode} - ${response.body}');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DeepSeek Service Error: $e');
|
print('DeepSeek Service Error: $e');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mockGenerateSubtasks(title, description);
|
return _mockGenerateSubtasks(title, description);
|
||||||
@@ -70,7 +153,8 @@ class DeepSeekService {
|
|||||||
required String description,
|
required String description,
|
||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
}) async {
|
}) async {
|
||||||
if (apiKey == null || apiKey!.isEmpty) {
|
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||||
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
return _mockPrioritizeTask(dueDate);
|
return _mockPrioritizeTask(dueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +199,12 @@ class DeepSeekService {
|
|||||||
default:
|
default:
|
||||||
return TaskPriority.medium;
|
return TaskPriority.medium;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DeepSeek Service Error: $e');
|
print('DeepSeek Service Error: $e');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mockPrioritizeTask(dueDate);
|
return _mockPrioritizeTask(dueDate);
|
||||||
@@ -128,8 +215,9 @@ class DeepSeekService {
|
|||||||
required String title,
|
required String title,
|
||||||
String? context,
|
String? context,
|
||||||
}) async {
|
}) async {
|
||||||
if (apiKey == null || apiKey!.isEmpty) {
|
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||||
return 'Описание для задачи: $title';
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
|
return _mockGenerateDescription(title, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -161,12 +249,15 @@ class DeepSeekService {
|
|||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
return data['choices'][0]['message']['content'];
|
return data['choices'][0]['message']['content'];
|
||||||
|
} else {
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DeepSeek Service Error: $e');
|
print('DeepSeek Service Error: $e');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Описание для задачи: $title';
|
return _mockGenerateDescription(title, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Умное планирование - распределение задач по времени
|
// Умное планирование - распределение задач по времени
|
||||||
@@ -176,7 +267,8 @@ class DeepSeekService {
|
|||||||
required DateTime endDate,
|
required DateTime endDate,
|
||||||
required int availableHoursPerDay,
|
required int availableHoursPerDay,
|
||||||
}) async {
|
}) async {
|
||||||
if (apiKey == null || apiKey!.isEmpty) {
|
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||||
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,9 +330,12 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}]
|
|||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DeepSeek Service Error: $e');
|
print('DeepSeek Service Error: $e');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay);
|
||||||
@@ -251,7 +346,8 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}]
|
|||||||
required String projectName,
|
required String projectName,
|
||||||
required String description,
|
required String description,
|
||||||
}) async {
|
}) async {
|
||||||
if (apiKey == null || apiKey!.isEmpty) {
|
// Возвращаем mock данные пока нет баланса на DeepSeek
|
||||||
|
if (useMockMode || apiKey == null || apiKey!.isEmpty) {
|
||||||
return _mockMindMap(projectName);
|
return _mockMindMap(projectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +393,12 @@ Return JSON array of nodes with format:
|
|||||||
final List<dynamic> nodes = jsonDecode(jsonMatch.group(0)!);
|
final List<dynamic> nodes = jsonDecode(jsonMatch.group(0)!);
|
||||||
return nodes.cast<Map<String, dynamic>>();
|
return nodes.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DeepSeek Service Error: $e');
|
print('DeepSeek Service Error: $e');
|
||||||
|
print('⚠️ Используется mock режим как fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mockMindMap(projectName);
|
return _mockMindMap(projectName);
|
||||||
@@ -348,6 +447,24 @@ Return JSON array of nodes with format:
|
|||||||
return TaskPriority.low;
|
return TaskPriority.low;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _mockGenerateDescription(String title, String? context) {
|
||||||
|
final descriptions = {
|
||||||
|
'Анализ требований':
|
||||||
|
'Изучить и проанализировать все требования проекта. Подготовить документацию с детальным описанием функционала и ожиданий пользователей.',
|
||||||
|
'Проектирование':
|
||||||
|
'Разработать архитектуру решения, создать схемы базы данных и определить основные компоненты системы.',
|
||||||
|
'Реализация':
|
||||||
|
'Написать чистый, поддерживаемый код согласно лучшим практикам. Реализовать весь запланированный функционал.',
|
||||||
|
'Тестирование':
|
||||||
|
'Провести комплексное тестирование: модульные, интеграционные и end-to-end тесты для обеспечения качества.',
|
||||||
|
'Деплой':
|
||||||
|
'Подготовить инфраструктуру, настроить CI/CD пайплайн и развернуть приложение на production сервере.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[title] ??
|
||||||
|
'Задача: $title. ${context ?? "Выполнить в срок с надлежащим качеством."}';
|
||||||
|
}
|
||||||
|
|
||||||
List<Map<String, dynamic>> _mockSchedule(
|
List<Map<String, dynamic>> _mockSchedule(
|
||||||
List<TaskModel> tasks,
|
List<TaskModel> tasks,
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import '../models/task_model.dart';
|
import '../models/task_model.dart';
|
||||||
import '../models/alarm_model.dart';
|
import '../models/alarm_model.dart';
|
||||||
import '../models/mind_map_model.dart';
|
import '../models/mind_map_model.dart';
|
||||||
@@ -29,20 +30,23 @@ class LocalStorageService {
|
|||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
Future<void> saveTask(TaskModel task) async {
|
Future<void> saveTask(TaskModel task) async {
|
||||||
await _tasksBox.put(task.id, task.toMap()); // Используем toMap() для Hive
|
final data = _convertTimestamps(task.toFirestore());
|
||||||
|
data['id'] = task.id; // Добавляем id для fromMap
|
||||||
|
await _tasksBox.put(task.id, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveTasks(List<TaskModel> tasks) async {
|
Future<void> saveTasks(List<TaskModel> tasks) async {
|
||||||
for (final task in tasks) {
|
for (final task in tasks) {
|
||||||
await _tasksBox.put(task.id, task.toMap());
|
final data = _convertTimestamps(task.toFirestore());
|
||||||
|
data['id'] = task.id;
|
||||||
|
await _tasksBox.put(task.id, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TaskModel> getTasks() {
|
List<TaskModel> getTasks() {
|
||||||
return _tasksBox.values.map((data) {
|
return _tasksBox.values.map((data) {
|
||||||
return TaskModel.fromFirestore(
|
final restoredData = _restoreTimestamps(data);
|
||||||
_createMockDocument(data as Map<String, dynamic>),
|
return TaskModel.fromMap(restoredData);
|
||||||
);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +56,14 @@ class LocalStorageService {
|
|||||||
|
|
||||||
// Alarms
|
// Alarms
|
||||||
Future<void> saveAlarm(AlarmModel alarm) async {
|
Future<void> saveAlarm(AlarmModel alarm) async {
|
||||||
await _alarmsBox.put(alarm.id, alarm.toFirestore());
|
await _alarmsBox.put(alarm.id, _convertTimestamps(alarm.toFirestore()));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AlarmModel> getAlarms() {
|
List<AlarmModel> getAlarms() {
|
||||||
return _alarmsBox.values.map((data) {
|
return _alarmsBox.keys.map((id) {
|
||||||
return AlarmModel.fromFirestore(
|
final data = _alarmsBox.get(id) as Map<dynamic, dynamic>;
|
||||||
_createMockDocument(data as Map<String, dynamic>),
|
final restoredData = _restoreTimestamps(data);
|
||||||
);
|
return AlarmModel.fromMap(id.toString(), restoredData);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,28 +74,28 @@ class LocalStorageService {
|
|||||||
// Mind Maps
|
// Mind Maps
|
||||||
Future<void> saveMindMapNodes(List<MindMapNode> nodes) async {
|
Future<void> saveMindMapNodes(List<MindMapNode> nodes) async {
|
||||||
for (final node in nodes) {
|
for (final node in nodes) {
|
||||||
await _mindMapsBox.put(node.id, node.toFirestore());
|
await _mindMapsBox.put(node.id, _convertTimestamps(node.toFirestore()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MindMapNode> getMindMapNodes() {
|
List<MindMapNode> getMindMapNodes() {
|
||||||
return _mindMapsBox.values.map((data) {
|
return _mindMapsBox.keys.map((id) {
|
||||||
return MindMapNode.fromFirestore(
|
final data = _mindMapsBox.get(id) as Map<dynamic, dynamic>;
|
||||||
_createMockDocument(data as Map<String, dynamic>),
|
final restoredData = _restoreTimestamps(data);
|
||||||
);
|
return MindMapNode.fromMap(id.toString(), restoredData);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
Future<void> saveEvent(CalendarEventModel event) async {
|
Future<void> saveEvent(CalendarEventModel event) async {
|
||||||
await _eventsBox.put(event.id, event.toFirestore());
|
await _eventsBox.put(event.id, _convertTimestamps(event.toFirestore()));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<CalendarEventModel> getEvents() {
|
List<CalendarEventModel> getEvents() {
|
||||||
return _eventsBox.values.map((data) {
|
return _eventsBox.keys.map((id) {
|
||||||
return CalendarEventModel.fromFirestore(
|
final data = _eventsBox.get(id) as Map<dynamic, dynamic>;
|
||||||
_createMockDocument(data as Map<String, dynamic>),
|
final restoredData = _restoreTimestamps(data);
|
||||||
);
|
return CalendarEventModel.fromMap(id.toString(), restoredData);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +128,49 @@ class LocalStorageService {
|
|||||||
|
|
||||||
await _tasksBox.clear();
|
await _tasksBox.clear();
|
||||||
for (final task in merged.values) {
|
for (final task in merged.values) {
|
||||||
await _tasksBox.put(task.id, task.toFirestore());
|
await _tasksBox.put(task.id, _convertTimestamps(task.toFirestore()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to create mock DocumentSnapshot
|
// Конвертирует Firestore Timestamp в ISO строки для Hive
|
||||||
dynamic _createMockDocument(Map<String, dynamic> data) {
|
Map<String, dynamic> _convertTimestamps(Map<String, dynamic> data) {
|
||||||
// Простая эмуляция для конвертации
|
final converted = <String, dynamic>{};
|
||||||
return data;
|
for (final entry in data.entries) {
|
||||||
|
if (entry.value is Timestamp) {
|
||||||
|
// Конвертируем Timestamp → ISO строка
|
||||||
|
converted[entry.key] = {
|
||||||
|
'__type__': 'timestamp',
|
||||||
|
'value': (entry.value as Timestamp).toDate().toIso8601String(),
|
||||||
|
};
|
||||||
|
} else if (entry.value is Map<String, dynamic>) {
|
||||||
|
converted[entry.key] =
|
||||||
|
_convertTimestamps(entry.value as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
converted[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливает ISO строки обратно в Timestamp из Hive
|
||||||
|
Map<String, dynamic> _restoreTimestamps(Map<dynamic, dynamic> data) {
|
||||||
|
final converted = <String, dynamic>{};
|
||||||
|
for (final entry in data.entries) {
|
||||||
|
final key = entry.key.toString();
|
||||||
|
if (entry.value is Map &&
|
||||||
|
(entry.value as Map).containsKey('__type__') &&
|
||||||
|
(entry.value as Map)['__type__'] == 'timestamp') {
|
||||||
|
// Конвертируем ISO строку → Timestamp
|
||||||
|
final dateStr = (entry.value as Map)['value'] as String;
|
||||||
|
converted[key] = Timestamp.fromDate(DateTime.parse(dateStr));
|
||||||
|
} else if (entry.value is Map) {
|
||||||
|
converted[key] =
|
||||||
|
_restoreTimestamps(entry.value as Map<dynamic, dynamic>);
|
||||||
|
} else {
|
||||||
|
converted[key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||||
|
import '../models/mind_map_model.dart';
|
||||||
|
|
||||||
|
/// Адаптер для конвертации MindMapNode в формат reactive_mind_map
|
||||||
|
class MindMapAdapter {
|
||||||
|
/// Конвертирует список MindMapNode в rm.MindMapData для reactive_mind_map
|
||||||
|
static rm.MindMapData nodesToMindMapData(List<MindMapNode> nodes) {
|
||||||
|
if (nodes.isEmpty) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: 'empty',
|
||||||
|
title: 'Пустая карта',
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим корневой узел (level 0)
|
||||||
|
final rootNodes = nodes.where((n) => n.level == 0).toList();
|
||||||
|
|
||||||
|
if (rootNodes.isEmpty) {
|
||||||
|
// Если нет корневого узла, создаём его
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: 'auto_root',
|
||||||
|
title: 'Автоматический корень',
|
||||||
|
children: _buildChildren(nodes, null, nodes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final root = rootNodes.first;
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: root.id,
|
||||||
|
title: root.title,
|
||||||
|
children: _buildChildren(nodes, root.id, nodes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рекурсивно строит дочерние узлы
|
||||||
|
static List<rm.MindMapData> _buildChildren(
|
||||||
|
List<MindMapNode> allNodes,
|
||||||
|
String? parentId,
|
||||||
|
List<MindMapNode> nodes,
|
||||||
|
) {
|
||||||
|
final children = allNodes.where((n) => n.parentId == parentId).toList();
|
||||||
|
|
||||||
|
return children.map((node) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
children: _buildChildren(allNodes, node.id, nodes),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Конвертирует rm.MindMapData обратно в список MindMapNode
|
||||||
|
static List<MindMapNode> mindMapDataToNodes(
|
||||||
|
rm.MindMapData data,
|
||||||
|
String projectId, {
|
||||||
|
String? parentId,
|
||||||
|
int level = 0,
|
||||||
|
}) {
|
||||||
|
final nodes = <MindMapNode>[];
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
nodes.add(MindMapNode(
|
||||||
|
id: data.id,
|
||||||
|
projectId: projectId,
|
||||||
|
parentId: parentId,
|
||||||
|
level: level,
|
||||||
|
title: data.title,
|
||||||
|
color: '#2196F3', // Default color, можно улучшить хранить цвет в metadata
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Рекурсивно добавляем детей
|
||||||
|
for (final child in data.children!) {
|
||||||
|
nodes.addAll(mindMapDataToNodes(
|
||||||
|
child,
|
||||||
|
projectId,
|
||||||
|
parentId: data.id,
|
||||||
|
level: level + 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HEX → int (для reactive_mind_map)
|
||||||
|
static int _hexToColorInt(String hex) {
|
||||||
|
hex = hex.replaceAll('#', '');
|
||||||
|
if (hex.length == 6) {
|
||||||
|
return int.parse('FF$hex', radix: 16);
|
||||||
|
}
|
||||||
|
return 0xFF2196F3; // Default blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// int → HEX (для MindMapNode)
|
||||||
|
static String _colorIntToHex(int color) {
|
||||||
|
return '#${(color & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Сервис для синхронизации с Nanaxe Server
|
||||||
|
/// Поддерживает работу с mindmaps через REST API
|
||||||
|
class NanaxeServerService {
|
||||||
|
static final NanaxeServerService _instance = NanaxeServerService._internal();
|
||||||
|
factory NanaxeServerService() => _instance;
|
||||||
|
NanaxeServerService._internal();
|
||||||
|
|
||||||
|
String? _baseUrl;
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
|
static const String _prefsUrlKey = 'nanaxe_server_url';
|
||||||
|
static const String _prefsTokenKey = 'nanaxe_server_token';
|
||||||
|
|
||||||
|
/// Инициализация сервиса
|
||||||
|
Future<void> initialize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_baseUrl = prefs.getString(_prefsUrlKey);
|
||||||
|
_authToken = prefs.getString(_prefsTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Настройка сервера
|
||||||
|
Future<void> configure(String baseUrl, {String? token}) async {
|
||||||
|
_baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''); // Remove trailing slashes
|
||||||
|
_authToken = token;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefsUrlKey, _baseUrl!);
|
||||||
|
if (token != null) {
|
||||||
|
await prefs.setString(_prefsTokenKey, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Очистка конфигурации
|
||||||
|
Future<void> clearConfiguration() async {
|
||||||
|
_baseUrl = null;
|
||||||
|
_authToken = null;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_prefsUrlKey);
|
||||||
|
await prefs.remove(_prefsTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверка, настроен ли сервер
|
||||||
|
bool get isConfigured => _baseUrl != null;
|
||||||
|
|
||||||
|
/// Получение базового URL
|
||||||
|
String? get baseUrl => _baseUrl;
|
||||||
|
|
||||||
|
// ==================== AUTH ====================
|
||||||
|
|
||||||
|
/// Регистрация нового пользователя
|
||||||
|
Future<Map<String, dynamic>> register(String email, String password, String username) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/auth/register'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'username': username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
_authToken = data['token'];
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefsTokenKey, _authToken!);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw Exception(data['error'] ?? 'Registration failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вход в систему
|
||||||
|
Future<Map<String, dynamic>> login(String email, String password) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/auth/login'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
_authToken = data['token'];
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefsTokenKey, _authToken!);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw Exception(data['error'] ?? 'Login failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверка валидности токена
|
||||||
|
Future<bool> validateToken() async {
|
||||||
|
if (_baseUrl == null || _authToken == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/auth/validate'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['valid'] == true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MINDMAPS ====================
|
||||||
|
|
||||||
|
/// Получение списка всех mindmaps
|
||||||
|
Future<List<Map<String, dynamic>>> getMindMaps() async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return List<Map<String, dynamic>>.from(data['mindmaps'] ?? []);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load mindmaps');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получение одной mindmap
|
||||||
|
Future<Map<String, dynamic>> getMindMap(String id) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load mindmap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создание новой mindmap
|
||||||
|
Future<Map<String, dynamic>> createMindMap({
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
List<Map<String, dynamic>>? nodes,
|
||||||
|
Map<String, dynamic>? settings,
|
||||||
|
}) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
body: jsonEncode({
|
||||||
|
'title': title,
|
||||||
|
'description': description ?? '',
|
||||||
|
'nodes': nodes ?? [],
|
||||||
|
'settings': settings ?? {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
throw Exception(data['error'] ?? 'Failed to create mindmap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обновление mindmap
|
||||||
|
Future<Map<String, dynamic>> updateMindMap(
|
||||||
|
String id, {
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
List<Map<String, dynamic>>? nodes,
|
||||||
|
Map<String, dynamic>? settings,
|
||||||
|
}) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (title != null) body['title'] = title;
|
||||||
|
if (description != null) body['description'] = description;
|
||||||
|
if (nodes != null) body['nodes'] = nodes;
|
||||||
|
if (settings != null) body['settings'] = settings;
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to update mindmap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаление mindmap
|
||||||
|
Future<void> deleteMindMap(String id) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps/$id'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to delete mindmap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получение mindmap как Markdown
|
||||||
|
Future<String> getMindMapAsMarkdown(String id) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.body;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load markdown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохранение mindmap из Markdown
|
||||||
|
Future<Map<String, dynamic>> saveMindMapFromMarkdown(
|
||||||
|
String id,
|
||||||
|
String markdown,
|
||||||
|
) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
body: jsonEncode({'markdown': markdown}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to save markdown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SYNC ====================
|
||||||
|
|
||||||
|
/// Получение статуса синхронизации
|
||||||
|
Future<Map<String, dynamic>> getSyncStatus() async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_baseUrl/api/sync/status'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to get sync status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push изменений в Gitea
|
||||||
|
Future<Map<String, dynamic>> pushToGitea({String? commitMessage}) async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/sync/gitea/push'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
body: jsonEncode({
|
||||||
|
'commitMessage': commitMessage ?? 'Sync from Nanaxe CRM',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to push to Gitea');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull изменений из Gitea
|
||||||
|
Future<Map<String, dynamic>> pullFromGitea() async {
|
||||||
|
if (_baseUrl == null) throw Exception('Server not configured');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$_baseUrl/api/sync/gitea/pull'),
|
||||||
|
headers: _getHeaders(),
|
||||||
|
body: jsonEncode({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to pull from Gitea');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HELPERS ====================
|
||||||
|
|
||||||
|
Map<String, String> _getHeaders() {
|
||||||
|
final headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_authToken != null) {
|
||||||
|
headers['Authorization'] = 'Bearer $_authToken';
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверка соединения с сервером
|
||||||
|
Future<bool> checkConnection() async {
|
||||||
|
if (_baseUrl == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse('$_baseUrl/health'))
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Провайдер для работы с сервером
|
||||||
|
class ServerConfig {
|
||||||
|
final String baseUrl;
|
||||||
|
final String? token;
|
||||||
|
final bool isConnected;
|
||||||
|
|
||||||
|
ServerConfig({
|
||||||
|
required this.baseUrl,
|
||||||
|
this.token,
|
||||||
|
this.isConnected = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import '../screens/home_screen.dart';
|
|||||||
import '../screens/tasks_screen.dart';
|
import '../screens/tasks_screen.dart';
|
||||||
import '../screens/task_form_screen.dart';
|
import '../screens/task_form_screen.dart';
|
||||||
import '../screens/mind_map_screen.dart';
|
import '../screens/mind_map_screen.dart';
|
||||||
|
import '../screens/mind_map_settings_screen.dart';
|
||||||
import '../screens/calendar_screen.dart';
|
import '../screens/calendar_screen.dart';
|
||||||
import '../screens/alarms_screen.dart';
|
import '../screens/alarms_screen.dart';
|
||||||
import '../screens/ai_test_screen.dart';
|
import '../screens/ai_test_screen.dart';
|
||||||
|
import '../screens/profile_screen.dart';
|
||||||
import '../screens/admin/admin_screen.dart';
|
import '../screens/admin/admin_screen.dart';
|
||||||
import '../screens/admin/team_management_screen.dart';
|
import '../screens/admin/team_management_screen.dart';
|
||||||
import '../screens/admin/projects_management_screen.dart';
|
import '../screens/admin/projects_management_screen.dart';
|
||||||
import '../screens/admin/notifications_settings_screen.dart';
|
import '../screens/admin/notifications_settings_screen.dart';
|
||||||
|
import '../screens/admin/mind_map_admin_screen.dart';
|
||||||
|
import '../screens/admin/ai_stats_screen.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
// Глобальный ключ для навигации
|
// Глобальный ключ для навигации
|
||||||
@@ -67,6 +71,17 @@ class AppRouter {
|
|||||||
path: 'mindmap',
|
path: 'mindmap',
|
||||||
name: 'mindmap',
|
name: 'mindmap',
|
||||||
builder: (context, state) => const MindMapScreen(),
|
builder: (context, state) => const MindMapScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'settings',
|
||||||
|
name: 'mindmap-settings',
|
||||||
|
builder: (context, state) {
|
||||||
|
final projectId =
|
||||||
|
state.uri.queryParameters['projectId'] ?? 'default';
|
||||||
|
return MindMapSettingsScreen(projectId: projectId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'calendar',
|
path: 'calendar',
|
||||||
@@ -78,6 +93,11 @@ class AppRouter {
|
|||||||
name: 'alarms',
|
name: 'alarms',
|
||||||
builder: (context, state) => const AlarmsScreen(),
|
builder: (context, state) => const AlarmsScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'profile',
|
||||||
|
name: 'profile',
|
||||||
|
builder: (context, state) => const ProfileScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'ai-test',
|
path: 'ai-test',
|
||||||
name: 'ai-test',
|
name: 'ai-test',
|
||||||
@@ -104,6 +124,16 @@ class AppRouter {
|
|||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
const NotificationsSettingsScreen(),
|
const NotificationsSettingsScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'mindmaps',
|
||||||
|
name: 'admin-mindmaps',
|
||||||
|
builder: (context, state) => const MindMapAdminScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'ai-stats',
|
||||||
|
name: 'admin-ai-stats',
|
||||||
|
builder: (context, state) => const AIStatsScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class AlarmModel {
|
|||||||
|
|
||||||
factory AlarmModel.fromFirestore(DocumentSnapshot doc) {
|
factory AlarmModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
return AlarmModel.fromMap(doc.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AlarmModel.fromMap(String id, Map<String, dynamic> data) {
|
||||||
return AlarmModel(
|
return AlarmModel(
|
||||||
id: doc.id,
|
id: id,
|
||||||
title: data['title'] ?? '',
|
title: data['title'] ?? '',
|
||||||
taskId: data['taskId'],
|
taskId: data['taskId'],
|
||||||
eventId: data['eventId'],
|
eventId: data['eventId'],
|
||||||
@@ -107,10 +111,10 @@ class AlarmModel {
|
|||||||
|
|
||||||
DateTime get nextOccurrence {
|
DateTime get nextOccurrence {
|
||||||
if (!isRepeating) return dateTime;
|
if (!isRepeating) return dateTime;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (dateTime.isAfter(now)) return dateTime;
|
if (dateTime.isAfter(now)) return dateTime;
|
||||||
|
|
||||||
switch (repeatPattern) {
|
switch (repeatPattern) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
return dateTime.add(const Duration(days: 1));
|
return dateTime.add(const Duration(days: 1));
|
||||||
@@ -124,7 +128,8 @@ class AlarmModel {
|
|||||||
while (!repeatDays.contains(next.weekday)) {
|
while (!repeatDays.contains(next.weekday)) {
|
||||||
next = next.add(const Duration(days: 1));
|
next = next.add(const Duration(days: 1));
|
||||||
}
|
}
|
||||||
return DateTime(next.year, next.month, next.day, dateTime.hour, dateTime.minute);
|
return DateTime(
|
||||||
|
next.year, next.month, next.day, dateTime.hour, dateTime.minute);
|
||||||
default:
|
default:
|
||||||
return dateTime;
|
return dateTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ class CalendarEventModel {
|
|||||||
|
|
||||||
factory CalendarEventModel.fromFirestore(DocumentSnapshot doc) {
|
factory CalendarEventModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
return CalendarEventModel.fromMap(doc.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CalendarEventModel.fromMap(String id, Map<String, dynamic> data) {
|
||||||
return CalendarEventModel(
|
return CalendarEventModel(
|
||||||
id: doc.id,
|
id: id,
|
||||||
title: data['title'] ?? '',
|
title: data['title'] ?? '',
|
||||||
description: data['description'],
|
description: data['description'],
|
||||||
location: data['location'] ?? '',
|
location: data['location'] ?? '',
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class MindMapNode {
|
|||||||
|
|
||||||
factory MindMapNode.fromFirestore(DocumentSnapshot doc) {
|
factory MindMapNode.fromFirestore(DocumentSnapshot doc) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
return MindMapNode.fromMap(doc.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MindMapNode.fromMap(String id, Map<String, dynamic> data) {
|
||||||
return MindMapNode(
|
return MindMapNode(
|
||||||
id: doc.id,
|
id: id,
|
||||||
projectId: data['projectId'] ?? '',
|
projectId: data['projectId'] ?? '',
|
||||||
parentId: data['parentId'],
|
parentId: data['parentId'],
|
||||||
level: data['level'] ?? 0,
|
level: data['level'] ?? 0,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class MindMapModel {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String ownerEmail;
|
||||||
|
final bool isMain; // Основные карты (админские) vs Личные
|
||||||
|
final List<Map<String, dynamic>> nodes;
|
||||||
|
final Map<String, dynamic> settings;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
MindMapModel({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.description = '',
|
||||||
|
required this.ownerEmail,
|
||||||
|
this.isMain = false,
|
||||||
|
this.nodes = const [],
|
||||||
|
this.settings = const {},
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MindMapModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
return MindMapModel.fromMap(doc.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MindMapModel.fromMap(String id, Map<String, dynamic> data) {
|
||||||
|
return MindMapModel(
|
||||||
|
id: id,
|
||||||
|
title: data['title'] ?? '',
|
||||||
|
description: data['description'] ?? '',
|
||||||
|
ownerEmail: data['ownerEmail'] ?? '',
|
||||||
|
isMain: data['isMain'] ?? false,
|
||||||
|
nodes: List<Map<String, dynamic>>.from(data['nodes'] ?? []),
|
||||||
|
settings: data['settings'] ?? {},
|
||||||
|
createdAt: (data['createdAt'] as Timestamp).toDate(),
|
||||||
|
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toFirestore() {
|
||||||
|
return {
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'ownerEmail': ownerEmail,
|
||||||
|
'isMain': isMain,
|
||||||
|
'nodes': nodes,
|
||||||
|
'settings': settings,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MindMapModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
String? ownerEmail,
|
||||||
|
bool? isMain,
|
||||||
|
List<Map<String, dynamic>>? nodes,
|
||||||
|
Map<String, dynamic>? settings,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return MindMapModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
ownerEmail: ownerEmail ?? this.ownerEmail,
|
||||||
|
isMain: isMain ?? this.isMain,
|
||||||
|
nodes: nodes ?? this.nodes,
|
||||||
|
settings: settings ?? this.settings,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get nodeCount => nodes.length;
|
||||||
|
int get maxLevel {
|
||||||
|
if (nodes.isEmpty) return 0;
|
||||||
|
return nodes.fold(
|
||||||
|
0, (max, node) => max > (node['level'] ?? 0) ? max : (node['level'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,24 +34,33 @@ class ProjectModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory ProjectModel.fromFirestore(DocumentSnapshot doc) {
|
factory ProjectModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final data = doc.data();
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Document data is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
final mapData = data is Map<String, dynamic>
|
||||||
|
? data
|
||||||
|
: (data as Map<dynamic, dynamic>)
|
||||||
|
.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
|
||||||
return ProjectModel(
|
return ProjectModel(
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: data['name'] ?? '',
|
name: mapData['name'] ?? '',
|
||||||
description: data['description'] ?? '',
|
description: mapData['description'] ?? '',
|
||||||
teamId: data['teamId'] ?? '',
|
teamId: mapData['teamId'] ?? '',
|
||||||
ownerId: data['ownerId'] ?? '',
|
ownerId: mapData['ownerId'] ?? '',
|
||||||
memberIds: List<String>.from(data['memberIds'] ?? []),
|
memberIds: List<String>.from(mapData['memberIds'] ?? []),
|
||||||
status: data['status'] ?? 'active',
|
status: mapData['status'] ?? 'active',
|
||||||
startDate: (data['startDate'] as Timestamp).toDate(),
|
startDate: (mapData['startDate'] as Timestamp).toDate(),
|
||||||
endDate: data['endDate'] != null
|
endDate: mapData['endDate'] != null
|
||||||
? (data['endDate'] as Timestamp).toDate()
|
? (mapData['endDate'] as Timestamp).toDate()
|
||||||
: null,
|
: null,
|
||||||
color: data['color'] ?? '#1976D2',
|
color: mapData['color'] ?? '#1976D2',
|
||||||
icon: data['icon'] ?? 'project',
|
icon: mapData['icon'] ?? 'project',
|
||||||
settings: data['settings'] ?? {},
|
settings: mapData['settings'] ?? {},
|
||||||
createdAt: (data['createdAt'] as Timestamp).toDate(),
|
createdAt: (mapData['createdAt'] as Timestamp).toDate(),
|
||||||
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
|
updatedAt: (mapData['updatedAt'] as Timestamp).toDate(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/mindmap_model.dart';
|
||||||
|
import '../core/admin_access_service.dart';
|
||||||
|
import '../core/deepseek_service.dart';
|
||||||
|
|
||||||
|
// Provider для получения текущего email пользователя
|
||||||
|
final currentUserEmailProvider = Provider<String?>((ref) {
|
||||||
|
return FirebaseAuth.instance.currentUser?.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider проверки является ли пользователь админом
|
||||||
|
final isAdminProvider = Provider<bool>((ref) {
|
||||||
|
final email = ref.watch(currentUserEmailProvider);
|
||||||
|
if (email == null) return false;
|
||||||
|
return AdminAccessService.adminEmails.contains(email.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider списка mind maps
|
||||||
|
final mindMapsProvider = StreamProvider<List<MindMapModel>>((ref) {
|
||||||
|
return FirebaseFirestore.instance
|
||||||
|
.collection('mind_maps')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
return snapshot.docs.map((doc) {
|
||||||
|
return MindMapModel.fromFirestore(doc);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notifier для управления mind maps
|
||||||
|
class MindMapsNotifier extends StateNotifier<AsyncValue<List<MindMapModel>>> {
|
||||||
|
final Ref ref;
|
||||||
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||||
|
|
||||||
|
MindMapsNotifier(this.ref) : super(const AsyncValue.loading()) {
|
||||||
|
_listenToMindMaps();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _listenToMindMaps() {
|
||||||
|
_firestore
|
||||||
|
.collection('mind_maps')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.listen(
|
||||||
|
(snapshot) {
|
||||||
|
final mindMaps = snapshot.docs.map((doc) {
|
||||||
|
return MindMapModel.fromFirestore(doc);
|
||||||
|
}).toList();
|
||||||
|
state = AsyncValue.data(mindMaps);
|
||||||
|
},
|
||||||
|
onError: (error, st) {
|
||||||
|
state = AsyncValue.error(error, st);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание новой mind map
|
||||||
|
Future<String> createMindMap({
|
||||||
|
required String title,
|
||||||
|
String description = '',
|
||||||
|
required List<Map<String, dynamic>> nodes,
|
||||||
|
bool isMain = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final userEmail = _auth.currentUser?.email ?? 'unknown';
|
||||||
|
final now = DateTime.now();
|
||||||
|
final id = const Uuid().v4();
|
||||||
|
|
||||||
|
final mindMap = MindMapModel(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
ownerEmail: userEmail,
|
||||||
|
isMain: isMain,
|
||||||
|
nodes: nodes,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _firestore.collection('mind_maps').doc(id).set(mindMap.toFirestore());
|
||||||
|
|
||||||
|
return id;
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление mind map (админы могут удалять любые, обычные - только свои)
|
||||||
|
Future<void> deleteMindMap(String id, String ownerEmail) async {
|
||||||
|
try {
|
||||||
|
final isAdmin = ref.read(isAdminProvider);
|
||||||
|
final currentUserEmail = _auth.currentUser?.email ?? '';
|
||||||
|
|
||||||
|
// Проверка прав
|
||||||
|
if (!isAdmin && ownerEmail != currentUserEmail) {
|
||||||
|
throw Exception('Нет прав для удаления этой mind map');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _firestore.collection('mind_maps').doc(id).delete();
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление mind map
|
||||||
|
Future<void> updateMindMap(String id, MindMapModel mindMap) async {
|
||||||
|
try {
|
||||||
|
final isAdmin = ref.read(isAdminProvider);
|
||||||
|
final currentUserEmail = _auth.currentUser?.email ?? '';
|
||||||
|
|
||||||
|
// Проверка прав
|
||||||
|
if (!isAdmin && mindMap.ownerEmail != currentUserEmail) {
|
||||||
|
throw Exception('Нет прав для редактирования этой mind map');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _firestore
|
||||||
|
.collection('mind_maps')
|
||||||
|
.doc(id)
|
||||||
|
.update(mindMap.copyWith(updatedAt: DateTime.now()).toFirestore());
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI генерация mind map
|
||||||
|
Future<String> generateWithAI({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final nodesData = await deepSeekService.generateMindMap(
|
||||||
|
projectName: title,
|
||||||
|
description: description,
|
||||||
|
);
|
||||||
|
|
||||||
|
final nodes = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < nodesData.length; i++) {
|
||||||
|
final nodeData = nodesData[i];
|
||||||
|
nodes.add({
|
||||||
|
'id': 'node_$i',
|
||||||
|
'level': nodeData['level'] ?? 0,
|
||||||
|
'parentId': nodeData['parentId'],
|
||||||
|
'title': nodeData['title'] ?? '',
|
||||||
|
'color': _getColorForLevel(nodeData['level'] ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createMindMap(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
nodes: nodes,
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncValue.error(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getColorForLevel(int level) {
|
||||||
|
final colors = [
|
||||||
|
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4'
|
||||||
|
];
|
||||||
|
return colors[level % colors.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mindMapsNotifierProvider =
|
||||||
|
StateNotifierProvider<MindMapsNotifier, AsyncValue<List<MindMapModel>>>(
|
||||||
|
(ref) => MindMapsNotifier(ref),
|
||||||
|
);
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||||
import '../models/mind_map_model.dart';
|
import '../models/mind_map_model.dart';
|
||||||
import '../models/task_model.dart';
|
|
||||||
import '../core/firestore_service.dart';
|
import '../core/firestore_service.dart';
|
||||||
import '../core/local_storage_service.dart';
|
import '../core/local_storage_service.dart';
|
||||||
import '../core/deepseek_service.dart';
|
import '../core/deepseek_service.dart';
|
||||||
|
import '../core/mind_map_adapter.dart';
|
||||||
|
|
||||||
// Provider для mind-карты проекта
|
// Provider для mind-карты проекта
|
||||||
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier, AsyncValue<MindMapData>, String>((ref, projectId) {
|
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier,
|
||||||
|
AsyncValue<MindMapData>, String>((ref, projectId) {
|
||||||
return MindMapNotifier(projectId);
|
return MindMapNotifier(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,9 +33,16 @@ class MindMapData {
|
|||||||
|
|
||||||
// Получить родительский узел
|
// Получить родительский узел
|
||||||
MindMapNode? getParent(String nodeId) {
|
MindMapNode? getParent(String nodeId) {
|
||||||
final node = nodes.firstWhere((n) => n.id == nodeId, orElse: () => throw Exception('Node not found'));
|
final node = nodes.firstWhere((n) => n.id == nodeId,
|
||||||
|
orElse: () => throw Exception('Node not found'));
|
||||||
if (node.parentId == null) return null;
|
if (node.parentId == null) return null;
|
||||||
return nodes.firstWhere((n) => n.id == node.parentId, orElse: () => throw Exception('Parent not found'));
|
return nodes.firstWhere((n) => n.id == node.parentId,
|
||||||
|
orElse: () => throw Exception('Parent not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конвертировать в reactive_mind_map формат
|
||||||
|
rm.MindMapData toReactiveMindMapData() {
|
||||||
|
return MindMapAdapter.nodesToMindMapData(nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +58,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
try {
|
try {
|
||||||
// Загружаем из локального хранилища
|
// Загружаем из локального хранилища
|
||||||
final localNodes = localStorageService.getMindMapNodes();
|
final localNodes = localStorageService.getMindMapNodes();
|
||||||
final projectNodes = localNodes.where((n) => n.projectId == projectId).toList();
|
final projectNodes =
|
||||||
|
localNodes.where((n) => n.projectId == projectId).toList();
|
||||||
|
|
||||||
state = AsyncValue.data(_createMindMapData(projectNodes));
|
state = AsyncValue.data(_createMindMapData(projectNodes));
|
||||||
|
|
||||||
// TODO: Загрузить из Firestore и синхронизировать
|
// TODO: Загрузить из Firestore и синхронизировать
|
||||||
@@ -90,7 +100,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
String icon = 'folder',
|
String icon = 'folder',
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final parentNode = parentId != null
|
final parentNode = parentId != null
|
||||||
? state.value?.nodes.firstWhere((n) => n.id == parentId)
|
? state.value?.nodes.firstWhere((n) => n.id == parentId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -123,11 +133,11 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
final currentData = state.value;
|
final currentData = state.value;
|
||||||
if (currentData != null) {
|
if (currentData != null) {
|
||||||
final updatedNodes = [...currentData.nodes, node];
|
final updatedNodes = [...currentData.nodes, node];
|
||||||
|
|
||||||
if (parentId != null) {
|
if (parentId != null) {
|
||||||
currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id);
|
currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
state = AsyncValue.data(MindMapData(
|
state = AsyncValue.data(MindMapData(
|
||||||
nodes: updatedNodes,
|
nodes: updatedNodes,
|
||||||
childrenMap: currentData.childrenMap,
|
childrenMap: currentData.childrenMap,
|
||||||
@@ -143,7 +153,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обновление узла
|
// Обновление узла
|
||||||
Future<void> updateNode(String nodeId, {
|
Future<void> updateNode(
|
||||||
|
String nodeId, {
|
||||||
String? title,
|
String? title,
|
||||||
String? color,
|
String? color,
|
||||||
String? icon,
|
String? icon,
|
||||||
@@ -203,7 +214,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
_collectChildNodes(nodeId, currentData, nodesToDelete);
|
_collectChildNodes(nodeId, currentData, nodesToDelete);
|
||||||
|
|
||||||
// Сохраняем для удаления
|
// Сохраняем для удаления
|
||||||
final nodes = currentData.nodes.where((n) => !nodesToDelete.contains(n.id)).toList();
|
final nodes = currentData.nodes
|
||||||
|
.where((n) => !nodesToDelete.contains(n.id))
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Сохраняем локально (удаляем)
|
// Сохраняем локально (удаляем)
|
||||||
for (final id in nodesToDelete) {
|
for (final id in nodesToDelete) {
|
||||||
@@ -234,7 +247,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Рекурсивный сбор дочерних узлов
|
// Рекурсивный сбор дочерних узлов
|
||||||
void _collectChildNodes(String parentId, MindMapData data, Set<String> result) {
|
void _collectChildNodes(
|
||||||
|
String parentId, MindMapData data, Set<String> result) {
|
||||||
final childIds = data.childrenMap[parentId] ?? [];
|
final childIds = data.childrenMap[parentId] ?? [];
|
||||||
for (final childId in childIds) {
|
for (final childId in childIds) {
|
||||||
result.add(childId);
|
result.add(childId);
|
||||||
@@ -260,7 +274,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
final nodeData = nodesData[i];
|
final nodeData = nodesData[i];
|
||||||
final level = nodeData['level'] as int;
|
final level = nodeData['level'] as int;
|
||||||
final parentId = nodeData['parentId'] as int?;
|
final parentId = nodeData['parentId'] as int?;
|
||||||
|
|
||||||
String? actualParentId;
|
String? actualParentId;
|
||||||
if (parentId != null && parentId < nodes.length) {
|
if (parentId != null && parentId < nodes.length) {
|
||||||
actualParentId = nodes[parentId].id;
|
actualParentId = nodes[parentId].id;
|
||||||
@@ -272,16 +286,16 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
x: (level * 200).toDouble(), // Автоматическое позиционирование
|
x: (level * 200).toDouble(), // Автоматическое позиционирование
|
||||||
y: (i * 50).toDouble(),
|
y: (i * 50).toDouble(),
|
||||||
);
|
);
|
||||||
|
|
||||||
nodes.add(node);
|
nodes.add(node);
|
||||||
|
|
||||||
if (level == 0) {
|
if (level == 0) {
|
||||||
rootNode = node;
|
rootNode = node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
} catch (e, st) {
|
} catch (e) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,16 +314,18 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
|
|||||||
return {
|
return {
|
||||||
'projectId': projectId,
|
'projectId': projectId,
|
||||||
'rootNodeId': data.rootNodeId,
|
'rootNodeId': data.rootNodeId,
|
||||||
'nodes': data.nodes.map((n) => {
|
'nodes': data.nodes
|
||||||
'id': n.id,
|
.map((n) => {
|
||||||
'parentId': n.parentId,
|
'id': n.id,
|
||||||
'level': n.level,
|
'parentId': n.parentId,
|
||||||
'title': n.title,
|
'level': n.level,
|
||||||
'x': n.x,
|
'title': n.title,
|
||||||
'y': n.y,
|
'x': n.x,
|
||||||
'color': n.color,
|
'y': n.y,
|
||||||
'icon': n.icon,
|
'color': n.color,
|
||||||
}).toList(),
|
'icon': n.icon,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/task_model.dart';
|
import '../models/task_model.dart';
|
||||||
import '../models/project_model.dart';
|
|
||||||
import '../core/firestore_service.dart';
|
import '../core/firestore_service.dart';
|
||||||
import '../core/local_storage_service.dart';
|
import '../core/local_storage_service.dart';
|
||||||
import '../core/deepseek_service.dart';
|
import '../core/deepseek_service.dart';
|
||||||
@@ -253,7 +252,7 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return subtasks;
|
return subtasks;
|
||||||
} catch (e, st) {
|
} catch (e) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AIStatsScreen extends ConsumerStatefulWidget {
|
||||||
|
const AIStatsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AIStatsScreen> createState() => _AIStatsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AIStatsScreenState extends ConsumerState<AIStatsScreen> {
|
||||||
|
int _totalRequests = 0;
|
||||||
|
int _totalTokens = 0;
|
||||||
|
int _mockRequests = 0;
|
||||||
|
int _realRequests = 0;
|
||||||
|
Map<String, int> _requestsByType = {};
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStats() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
setState(() {
|
||||||
|
_totalRequests = prefs.getInt('ai_total_requests') ?? 0;
|
||||||
|
_totalTokens = prefs.getInt('ai_total_tokens') ?? 0;
|
||||||
|
_mockRequests = prefs.getInt('ai_mock_requests') ?? 0;
|
||||||
|
_realRequests = prefs.getInt('ai_real_requests') ?? 0;
|
||||||
|
_requestsByType = {
|
||||||
|
'Генерация подзадач': prefs.getInt('ai_subtasks') ?? 0,
|
||||||
|
'Приоритизация': prefs.getInt('ai_priority') ?? 0,
|
||||||
|
'Описания': prefs.getInt('ai_descriptions') ?? 0,
|
||||||
|
'Mind-карты': prefs.getInt('ai_mindmaps') ?? 0,
|
||||||
|
'Планирование': prefs.getInt('ai_scheduling') ?? 0,
|
||||||
|
};
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetStats() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Сбросить статистику?'),
|
||||||
|
content: const Text('Все данные об использовании AI будут удалены'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Сбросить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('ai_total_requests');
|
||||||
|
await prefs.remove('ai_total_tokens');
|
||||||
|
await prefs.remove('ai_mock_requests');
|
||||||
|
await prefs.remove('ai_real_requests');
|
||||||
|
await prefs.remove('ai_subtasks');
|
||||||
|
await prefs.remove('ai_priority');
|
||||||
|
await prefs.remove('ai_descriptions');
|
||||||
|
await prefs.remove('ai_mindmaps');
|
||||||
|
await prefs.remove('ai_scheduling');
|
||||||
|
|
||||||
|
await _loadStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('AI Статистика'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _loadStats,
|
||||||
|
tooltip: 'Обновить',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_sweep),
|
||||||
|
onPressed: _resetStats,
|
||||||
|
tooltip: 'Сбросить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Общая статистика
|
||||||
|
_buildStatCard(
|
||||||
|
'Общая статистика',
|
||||||
|
[
|
||||||
|
_buildBigStat('Всего запросов', _totalRequests.toString()),
|
||||||
|
const Divider(),
|
||||||
|
_buildStatRow('Mock запросов', _mockRequests.toString()),
|
||||||
|
_buildStatRow('Реальных запросов', _realRequests.toString()),
|
||||||
|
_buildStatRow('Использовано токенов', _totalTokens.toString()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Запросы по типам
|
||||||
|
_buildStatCard(
|
||||||
|
'Запросы по типам',
|
||||||
|
_requestsByType.entries
|
||||||
|
.map((e) => _buildStatRow(e.key, e.value.toString()))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// График использования (простой)
|
||||||
|
_buildStatCard(
|
||||||
|
'Использование',
|
||||||
|
[
|
||||||
|
SizedBox(
|
||||||
|
height: 150,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: _requestsByType.entries.map((entry) {
|
||||||
|
final max = _requestsByType.values.fold(
|
||||||
|
1, (prev, element) => prev > element ? prev : element);
|
||||||
|
final height = (entry.value / max) * 100;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(entry.value.toString()),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
width: 30,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.blue, Colors.blueAccent],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(
|
||||||
|
entry.key.split(' ')[0],
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Информация
|
||||||
|
_buildInfoCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(String title, List<Widget> children) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBigStat(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, color: Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Информация',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'• Сейчас работает MOCK режим\n'
|
||||||
|
'• Реальные запросы к DeepSeek отключены\n'
|
||||||
|
'• Токены не расходуются\n\n'
|
||||||
|
'Для включения реального AI:\n'
|
||||||
|
'1. Пополните баланс DeepSeek\n'
|
||||||
|
'2. Установите useMockMode = false\n'
|
||||||
|
'3. Перезапустите приложение',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class MindMapAdminScreen extends ConsumerStatefulWidget {
|
||||||
|
const MindMapAdminScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MindMapAdminScreen> createState() => _MindMapAdminScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MindMapAdminScreenState extends ConsumerState<MindMapAdminScreen> {
|
||||||
|
String _searchQuery = '';
|
||||||
|
String? _filterProject;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Управление Mind Maps'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => setState(() {}),
|
||||||
|
tooltip: 'Обновить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_buildSearchBar(),
|
||||||
|
_buildFilterBar(),
|
||||||
|
Expanded(child: _buildMindMapsList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchBar() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Поиск mind-карт...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () => setState(() => _searchQuery = ''),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) => setState(() => _searchQuery = value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterBar() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_list, color: Colors.grey),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Все проекты'),
|
||||||
|
selected: _filterProject == null,
|
||||||
|
onSelected: (selected) =>
|
||||||
|
setState(() => _filterProject = null),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Проект A'),
|
||||||
|
selected: _filterProject == 'project_a',
|
||||||
|
onSelected: (selected) =>
|
||||||
|
setState(() => _filterProject = 'project_a'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Проект B'),
|
||||||
|
selected: _filterProject == 'project_b',
|
||||||
|
onSelected: (selected) =>
|
||||||
|
setState(() => _filterProject = 'project_b'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMindMapsList() {
|
||||||
|
return StreamBuilder<QuerySnapshot>(
|
||||||
|
stream: FirebaseFirestore.instance
|
||||||
|
.collection('mind_map_nodes')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
print('Firestore error: ${snapshot.error}');
|
||||||
|
return _buildErrorState(snapshot.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final allDocs = snapshot.data?.docs ?? [];
|
||||||
|
print('Total docs from Firestore: ${allDocs.length}');
|
||||||
|
|
||||||
|
// Преобразуем в Map для безопасной работы
|
||||||
|
final mindMaps = <Map<String, dynamic>>[];
|
||||||
|
for (final doc in allDocs) {
|
||||||
|
try {
|
||||||
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
data['_id'] = doc.id;
|
||||||
|
mindMaps.add(data);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error converting doc: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем по проекту
|
||||||
|
final filtered = mindMaps.where((data) {
|
||||||
|
final projectId = data['projectId'] ?? 'unknown';
|
||||||
|
final title = data['title']?.toString().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
final matchesProject =
|
||||||
|
_filterProject == null || projectId == _filterProject;
|
||||||
|
final matchesSearch = title.contains(_searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
return matchesProject && matchesSearch;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return _buildEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final data = filtered[index];
|
||||||
|
final id = data['_id'] as String;
|
||||||
|
return _buildMindMapCard(id, data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMindMapCard(String id, Map<String, dynamic> data) {
|
||||||
|
final title = data['title'] ?? 'Без названия';
|
||||||
|
final projectId = data['projectId'] ?? 'unknown';
|
||||||
|
final createdAt = data['createdAt'] != null
|
||||||
|
? (data['createdAt'] as Timestamp).toDate()
|
||||||
|
: DateTime.now();
|
||||||
|
final nodeCount = data['nodeCount'] ?? 0;
|
||||||
|
final maxLevel = data['maxLevel'] ?? 0;
|
||||||
|
final createdBy = data['createdBy'] ?? 'unknown';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ExpansionTile(
|
||||||
|
leading: const Icon(Icons.account_tree, color: Colors.blue),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Проект: $projectId'),
|
||||||
|
Text('Создана: ${_formatDate(createdAt)}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
onSelected: (value) => _handleAction(id, value, data),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'view',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.visibility, size: 20),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Просмотр'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'export',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.download, size: 20),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Экспорт'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildStatRow('Узлов', nodeCount.toString()),
|
||||||
|
_buildStatRow('Уровней', maxLevel.toString()),
|
||||||
|
_buildStatRow('Автор', createdBy),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _handleAction(id, 'export', data),
|
||||||
|
icon: const Icon(Icons.download, size: 18),
|
||||||
|
label: const Text('Экспорт JSON'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _handleAction(id, 'delete', data),
|
||||||
|
icon: const Icon(Icons.delete, size: 18),
|
||||||
|
label: const Text('Удалить'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_tree, size: 80, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Mind-карты не найдены',
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Создайте первую mind-карту',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorState(dynamic error) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Ошибка: $error'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => setState(() {}),
|
||||||
|
child: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAction(
|
||||||
|
String id, String action, Map<String, dynamic> data) async {
|
||||||
|
switch (action) {
|
||||||
|
case 'view':
|
||||||
|
// TODO: Открыть mind map
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Просмотр в разработке')),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'export':
|
||||||
|
await _exportMindMap(id, data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
await _confirmDelete(id, data['title'] ?? 'Без названия');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportMindMap(String id, Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final jsonData = {
|
||||||
|
'id': id,
|
||||||
|
'title': data['title'],
|
||||||
|
'projectId': data['projectId'],
|
||||||
|
'createdAt': data['createdAt']?.toString(),
|
||||||
|
'data': data,
|
||||||
|
};
|
||||||
|
|
||||||
|
final jsonStr = const JsonEncoder.withIndent(' ').convert(jsonData);
|
||||||
|
|
||||||
|
// TODO: Сохранить в файл
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('📤 Экспорт: скопируйте JSON из консоли'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
print('=== EXPORT JSON ===');
|
||||||
|
print(jsonStr);
|
||||||
|
print('=== END EXPORT ===');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка экспорта: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(String id, String title) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить mind-карту?'),
|
||||||
|
content: Text(
|
||||||
|
'"$title" будет удалена навсегда. Это действие нельзя отменить.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
await _deleteMindMap(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteMindMap(String id) async {
|
||||||
|
try {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('mind_map_nodes')
|
||||||
|
.doc(id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Mind-карта удалена'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка удаления: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(date);
|
||||||
|
|
||||||
|
if (diff.inMinutes < 1) return 'только что';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes} мин. назад';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours} ч. назад';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays} дн. назад';
|
||||||
|
|
||||||
|
return '${date.day}.${date.month}.${date.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|||||||
+242
-31
@@ -1,49 +1,260 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../models/alarm_model.dart';
|
||||||
|
import '../core/notification_service.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class AlarmsScreen extends StatelessWidget {
|
class AlarmsScreen extends ConsumerStatefulWidget {
|
||||||
const AlarmsScreen({super.key});
|
const AlarmsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
ConsumerState<AlarmsScreen> createState() => _AlarmsScreenState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Будильники'),
|
class _AlarmsScreenState extends ConsumerState<AlarmsScreen> {
|
||||||
),
|
List<AlarmModel> _alarms = [];
|
||||||
body: ListView.builder(
|
bool _isLoading = true;
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
itemCount: 5,
|
@override
|
||||||
itemBuilder: (context, index) {
|
void initState() {
|
||||||
return _AlarmCard(index: index);
|
super.initState();
|
||||||
},
|
_loadAlarms();
|
||||||
),
|
}
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
onPressed: () {},
|
Future<void> _loadAlarms() async {
|
||||||
icon: const Icon(Icons.add),
|
setState(() => _isLoading = true);
|
||||||
label: const Text('Новый будильник'),
|
// TODO: Загрузить из Firestore/Hive
|
||||||
|
setState(() {
|
||||||
|
_alarms = [];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleAlarm(AlarmModel alarm) async {
|
||||||
|
final updated = alarm.copyWith(isEnabled: !alarm.isEnabled);
|
||||||
|
setState(() {
|
||||||
|
final index = _alarms.indexWhere((a) => a.id == alarm.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_alarms[index] = updated;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated.isEnabled) {
|
||||||
|
await notificationService.scheduleNotification(
|
||||||
|
id: updated.hashCode,
|
||||||
|
title: updated.title,
|
||||||
|
body: 'Напоминание: ${updated.title}',
|
||||||
|
scheduledDate: updated.dateTime,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await notificationService.cancelNotification(updated.hashCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAlarm(String id) async {
|
||||||
|
final alarm = _alarms.firstWhere((a) => a.id == id);
|
||||||
|
await notificationService.cancelNotification(alarm.hashCode);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_alarms.removeWhere((a) => a.id == id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddAlarmDialog() async {
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
TimeOfDay selectedTime = TimeOfDay.now();
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: const Text('Новый будильник'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Название',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.calendar_today),
|
||||||
|
title: const Text('Дата'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${selectedDate.day}.${selectedDate.month}.${selectedDate.year}',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedDate,
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setDialogState(() => selectedDate = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.access_time),
|
||||||
|
title: const Text('Время'),
|
||||||
|
subtitle: Text(selectedTime.format(context)),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: selectedTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setDialogState(() => selectedTime = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (titleController.text.isEmpty) return;
|
||||||
|
|
||||||
|
final dateTime = DateTime(
|
||||||
|
selectedDate.year,
|
||||||
|
selectedDate.month,
|
||||||
|
selectedDate.day,
|
||||||
|
selectedTime.hour,
|
||||||
|
selectedTime.minute,
|
||||||
|
);
|
||||||
|
|
||||||
|
final alarm = AlarmModel(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
title: titleController.text,
|
||||||
|
dateTime: dateTime,
|
||||||
|
isEnabled: true,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_alarms.add(alarm);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationService.scheduleNotification(
|
||||||
|
id: alarm.hashCode,
|
||||||
|
title: alarm.title,
|
||||||
|
body: 'Будильник: ${alarm.title}',
|
||||||
|
scheduledDate: alarm.dateTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Создать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _AlarmCard extends StatelessWidget {
|
|
||||||
final int index;
|
|
||||||
|
|
||||||
const _AlarmCard({required this.index});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Будильники'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _loadAlarms,
|
||||||
|
tooltip: 'Обновить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _alarms.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _alarms.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final alarm = _alarms[index];
|
||||||
|
return _buildAlarmCard(alarm);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _showAddAlarmDialog,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.alarm, size: 100, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Нет будильников',
|
||||||
|
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Создайте первый будильник',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlarmCard(AlarmModel alarm) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: Icon(
|
||||||
backgroundColor: index % 2 == 0 ? Colors.blue : Colors.grey,
|
alarm.isEnabled ? Icons.alarm : Icons.alarm_off,
|
||||||
child: const Icon(Icons.alarm, color: Colors.white),
|
color: alarm.isEnabled ? Colors.blue : Colors.grey,
|
||||||
),
|
),
|
||||||
title: Text('Будильник #${index + 1}'),
|
title: Text(
|
||||||
subtitle: Text('${8 + index}:00'),
|
alarm.title,
|
||||||
trailing: Switch(
|
style: TextStyle(
|
||||||
value: index % 2 == 0,
|
fontWeight: FontWeight.bold,
|
||||||
onChanged: (value) {},
|
color: alarm.isEnabled ? null : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${alarm.dateTime.day}.${alarm.dateTime.month}.${alarm.dateTime.year} '
|
||||||
|
'${alarm.dateTime.hour.toString().padLeft(2, '0')}:'
|
||||||
|
'${alarm.dateTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Switch(
|
||||||
|
value: alarm.isEnabled,
|
||||||
|
onChanged: (_) => _toggleAlarm(alarm),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _deleteAlarm(alarm.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import '../models/calendar_event_model.dart';
|
import '../models/calendar_event_model.dart';
|
||||||
import '../core/google_calendar_service.dart';
|
|
||||||
|
|
||||||
class CalendarScreen extends ConsumerStatefulWidget {
|
class CalendarScreen extends ConsumerStatefulWidget {
|
||||||
const CalendarScreen({super.key});
|
const CalendarScreen({super.key});
|
||||||
@@ -267,8 +266,8 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
|||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final locationController = TextEditingController();
|
final locationController = TextEditingController();
|
||||||
|
|
||||||
TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0);
|
TimeOfDay startTime = const TimeOfDay(hour: 10, minute: 0);
|
||||||
TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0);
|
TimeOfDay endTime = const TimeOfDay(hour: 11, minute: 0);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -312,16 +311,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: const Text('Начало'),
|
title: const Text('Начало'),
|
||||||
subtitle: Text(_startTime.format(context)),
|
subtitle: Text(startTime.format(context)),
|
||||||
leading: const Icon(Icons.access_time),
|
leading: const Icon(Icons.access_time),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final picked = await showTimePicker(
|
final picked = await showTimePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: _startTime,
|
initialTime: startTime,
|
||||||
);
|
);
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
_startTime = picked;
|
startTime = picked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -330,16 +329,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: const Text('Конец'),
|
title: const Text('Конец'),
|
||||||
subtitle: Text(_endTime.format(context)),
|
subtitle: Text(endTime.format(context)),
|
||||||
leading: const Icon(Icons.access_time),
|
leading: const Icon(Icons.access_time),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final picked = await showTimePicker(
|
final picked = await showTimePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: _endTime,
|
initialTime: endTime,
|
||||||
);
|
);
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
_endTime = picked;
|
endTime = picked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dashboard_screen.dart';
|
|||||||
import 'tasks_screen.dart';
|
import 'tasks_screen.dart';
|
||||||
import 'mind_map_screen.dart';
|
import 'mind_map_screen.dart';
|
||||||
import 'calendar_screen.dart';
|
import 'calendar_screen.dart';
|
||||||
import 'alarms_screen.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/mindmap_model.dart';
|
||||||
|
import '../providers/mind_maps_provider.dart';
|
||||||
|
import '../core/admin_access_service.dart';
|
||||||
|
import '../core/deepseek_service.dart';
|
||||||
|
|
||||||
|
class MindMapEditorScreen extends ConsumerStatefulWidget {
|
||||||
|
final MindMapModel? mindMap; // null = создание новой
|
||||||
|
|
||||||
|
const MindMapEditorScreen({super.key, this.mindMap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MindMapEditorScreen> createState() => _MindMapEditorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MindMapEditorScreenState extends ConsumerState<MindMapEditorScreen> {
|
||||||
|
late TextEditingController _titleController;
|
||||||
|
late TextEditingController _descController;
|
||||||
|
late List<Map<String, dynamic>> _nodes;
|
||||||
|
late bool _isMain;
|
||||||
|
bool _isSaving = false;
|
||||||
|
bool _isGeneratingAI = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_titleController = TextEditingController(text: widget.mindMap?.title ?? '');
|
||||||
|
_descController = TextEditingController(text: widget.mindMap?.description ?? '');
|
||||||
|
_nodes = widget.mindMap?.nodes.toList() ?? [];
|
||||||
|
_isMain = widget.mindMap?.isMain ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (_titleController.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите название')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(mindMapsNotifierProvider.notifier);
|
||||||
|
|
||||||
|
if (widget.mindMap == null) {
|
||||||
|
// Создание новой
|
||||||
|
await notifier.createMindMap(
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descController.text.trim(),
|
||||||
|
nodes: _nodes,
|
||||||
|
isMain: _isMain,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Обновление существующей
|
||||||
|
final updated = widget.mindMap!.copyWith(
|
||||||
|
title: _titleController.text.trim(),
|
||||||
|
description: _descController.text.trim(),
|
||||||
|
nodes: _nodes,
|
||||||
|
isMain: _isMain,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await notifier.updateMindMap(widget.mindMap!.id, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(widget.mindMap == null ? '✅ Mind map создана' : '✅ Mind map сохранена'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() => _isSaving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNode({String? parentId}) async {
|
||||||
|
final title = await _showNodeTitleDialog();
|
||||||
|
if (title == null || title.isEmpty) return;
|
||||||
|
|
||||||
|
final parentNode = parentId != null
|
||||||
|
? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final newNode = {
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'title': title,
|
||||||
|
'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0,
|
||||||
|
'parentId': parentId,
|
||||||
|
'color': _getColorForLevel(parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(() => _nodes.add(newNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addChildNode(String parentId) async {
|
||||||
|
final title = await _showNodeTitleDialog();
|
||||||
|
if (title == null || title.isEmpty) return;
|
||||||
|
|
||||||
|
final parentNode = _nodes.firstWhere((n) => n['id'] == parentId);
|
||||||
|
|
||||||
|
final newNode = {
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'title': title,
|
||||||
|
'level': (parentNode['level'] ?? 0) + 1,
|
||||||
|
'parentId': parentId,
|
||||||
|
'color': _getColorForLevel((parentNode['level'] ?? 0) + 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(() => _nodes.add(newNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteNode(String nodeId) {
|
||||||
|
// Удаляем узел и всех его потомков
|
||||||
|
final nodesToDelete = <String>{nodeId};
|
||||||
|
_collectChildren(nodeId, nodesToDelete);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collectChildren(String parentId, Set<String> result) {
|
||||||
|
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
|
||||||
|
for (final child in children) {
|
||||||
|
final childId = child['id'] as String;
|
||||||
|
result.add(childId);
|
||||||
|
_collectChildren(childId, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _showNodeTitleDialog() async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Название узла'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Введите название',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, controller.text),
|
||||||
|
child: const Text('Добавить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateWithAI() async {
|
||||||
|
if (_titleController.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Введите название проекта')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isGeneratingAI = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final projectName = _titleController.text.trim();
|
||||||
|
final projectDesc = _descController.text.trim();
|
||||||
|
|
||||||
|
final nodesData = await deepSeekService.generateMindMap(
|
||||||
|
projectName: projectName,
|
||||||
|
description: projectDesc.isNotEmpty ? projectDesc : 'Mind map для проекта',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Преобразуем и добавляем к существующим узлам
|
||||||
|
final newNodes = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < nodesData.length; i++) {
|
||||||
|
final nodeData = nodesData[i];
|
||||||
|
final level = nodeData['level'] ?? 0;
|
||||||
|
final parentIdIndex = nodeData['parentId'] as int?;
|
||||||
|
|
||||||
|
// Находим реальный parentId по индексу
|
||||||
|
String? actualParentId;
|
||||||
|
if (parentIdIndex != null && parentIdIndex < newNodes.length) {
|
||||||
|
actualParentId = newNodes[parentIdIndex]['id'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
newNodes.add({
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'title': nodeData['title'] ?? '',
|
||||||
|
'level': level,
|
||||||
|
'parentId': actualParentId,
|
||||||
|
'color': _getColorForLevel(level),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем новые узлы к существующим
|
||||||
|
setState(() {
|
||||||
|
_nodes.addAll(newNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('✅ Добавлено ${newNodes.length} узлов'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setState(() => _isGeneratingAI = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getColorForLevel(int level) {
|
||||||
|
final colors = [
|
||||||
|
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4'
|
||||||
|
];
|
||||||
|
return colors[level % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isAdmin = ref.watch(isAdminProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.mindMap == null ? 'Новая Mind Map' : 'Редактировать'),
|
||||||
|
actions: [
|
||||||
|
// Переключатель Общая/Личная (только для админов)
|
||||||
|
if (isAdmin)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person, color: _isMain ? Colors.grey : Colors.white),
|
||||||
|
Switch(
|
||||||
|
value: _isMain,
|
||||||
|
onChanged: (value) => setState(() => _isMain = value),
|
||||||
|
),
|
||||||
|
Icon(Icons.star, color: _isMain ? Colors.amber : Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// AI Генерация
|
||||||
|
IconButton(
|
||||||
|
icon: _isGeneratingAI
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.auto_awesome),
|
||||||
|
onPressed: _isGeneratingAI ? null : _generateWithAI,
|
||||||
|
tooltip: 'AI Дополнение',
|
||||||
|
),
|
||||||
|
// Сохранить
|
||||||
|
IconButton(
|
||||||
|
icon: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
onPressed: _isSaving ? null : _save,
|
||||||
|
tooltip: 'Сохранить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Заголовок и описание
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Название',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.title),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.description),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
if (isAdmin)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isMain ? Icons.star : Icons.person,
|
||||||
|
color: _isMain ? Colors.amber : Colors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_isMain ? 'Основная (видна всем)' : 'Личная (только вам)',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isMain ? Colors.amber : Colors.blue,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Список узлов
|
||||||
|
Expanded(
|
||||||
|
child: _nodes.isEmpty
|
||||||
|
? _buildEmptyNodesState()
|
||||||
|
: _buildNodesTree(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Кнопки добавления
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _addNode(),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Корневой узел'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _isGeneratingAI ? null : _generateWithAI,
|
||||||
|
icon: const Icon(Icons.auto_awesome),
|
||||||
|
label: const Text('AI Дополнить'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyNodesState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_tree, size: 80, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Нет узлов',
|
||||||
|
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Добавьте узлы вручную или с помощью AI',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNodesTree() {
|
||||||
|
// Находим корневые узлы (без parentId)
|
||||||
|
final rootNodes = _nodes.where((n) => n['parentId'] == null).toList();
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: rootNodes.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildNodeWidget(rootNodes[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNodeWidget(Map<String, dynamic> node) {
|
||||||
|
final nodeId = node['id'] as String;
|
||||||
|
final title = node['title'] as String;
|
||||||
|
final level = node['level'] as int;
|
||||||
|
final color = _hexToColor(node['color'] as String? ?? '#2196F3');
|
||||||
|
final children = _nodes.where((n) => n['parentId'] == nodeId).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: EdgeInsets.only(left: level * 24.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
border: Border(left: BorderSide(color: color, width: 4)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: color,
|
||||||
|
child: Text(
|
||||||
|
'${level + 1}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text('Уровень $level'),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle, color: Colors.green),
|
||||||
|
onPressed: () => _addChildNode(nodeId),
|
||||||
|
tooltip: 'Добавить дочерний',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _confirmDeleteNode(nodeId, title),
|
||||||
|
tooltip: 'Удалить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Рекурсивно строим дочерние узлы
|
||||||
|
...children.map((child) => _buildNodeWidget(child)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDeleteNode(String nodeId, String title) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить узел?'),
|
||||||
|
content: Text('"$title" и все дочерние узлы будут удалены'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
_deleteNode(nodeId);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _hexToColor(String hex) {
|
||||||
|
hex = hex.replaceAll('#', '');
|
||||||
|
if (hex.length == 6) {
|
||||||
|
return Color(int.parse('FF$hex', radix: 16));
|
||||||
|
}
|
||||||
|
return const Color(0xFF2196F3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,835 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/mindmap_model.dart';
|
||||||
|
import '../providers/mind_maps_provider.dart';
|
||||||
|
import '../core/admin_access_service.dart';
|
||||||
|
import '../core/deepseek_service.dart';
|
||||||
|
|
||||||
|
class MindMapInteractiveScreen extends ConsumerStatefulWidget {
|
||||||
|
final MindMapModel mindMap;
|
||||||
|
|
||||||
|
const MindMapInteractiveScreen({super.key, required this.mindMap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MindMapInteractiveScreen> createState() =>
|
||||||
|
_MindMapInteractiveScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MindMapInteractiveScreenState
|
||||||
|
extends ConsumerState<MindMapInteractiveScreen> {
|
||||||
|
late List<Map<String, dynamic>> _nodes;
|
||||||
|
late bool _isMain;
|
||||||
|
bool _isEditing = false;
|
||||||
|
bool _showChat = false;
|
||||||
|
bool _isAISubmitting = false;
|
||||||
|
final TextEditingController _chatController = TextEditingController();
|
||||||
|
final List<ChatMessage> _chatMessages = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nodes = widget.mindMap.nodes.toList();
|
||||||
|
_isMain = widget.mindMap.isMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chatController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CHAT FUNCTIONS ==========
|
||||||
|
|
||||||
|
void _addChatMessage(String text, bool isUser, {AIAction? action}) {
|
||||||
|
setState(() {
|
||||||
|
_chatMessages.add(ChatMessage(
|
||||||
|
text: text,
|
||||||
|
isUser: isUser,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
action: action,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendToAI(String message) async {
|
||||||
|
if (message.trim().isEmpty) return;
|
||||||
|
|
||||||
|
_addChatMessage(message, true);
|
||||||
|
_chatController.clear();
|
||||||
|
setState(() => _isAISubmitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Отправляем запрос к DeepSeek
|
||||||
|
final response = await deepSeekService.chatCompletion(
|
||||||
|
messages: [
|
||||||
|
..._chatMessages
|
||||||
|
.where((m) => m.isUser || m.text.startsWith('AI:'))
|
||||||
|
.map((m) => {
|
||||||
|
'role': m.isUser ? 'user' : 'assistant',
|
||||||
|
'content': m.text,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': '''
|
||||||
|
Ты AI ассистент для работы с mind maps.
|
||||||
|
Текущая структура mind map "${widget.mindMap.title}":
|
||||||
|
${_nodes.map((n) => '${" " * (n["level"] ?? 0)}- ${n["title"]} (уровень ${n["level"] ?? 0})').join('\n')}
|
||||||
|
|
||||||
|
Когда пользователь просит что-то сделать с mind map, отвечай в формате:
|
||||||
|
ACTION: <add|edit|delete|suggest>
|
||||||
|
TARGET: <id узла или null>
|
||||||
|
CHANGES: [
|
||||||
|
{"action": "add", "title": "...", "level": 0, "parentId": null},
|
||||||
|
{"action": "edit", "id": "...", "title": "..."},
|
||||||
|
{"action": "delete", "id": "..."}
|
||||||
|
]
|
||||||
|
EXPLANATION: <объяснение на русском>
|
||||||
|
|
||||||
|
Или просто отвечай текстом если действие не требуется.
|
||||||
|
''',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Парсим ответ AI
|
||||||
|
if (response.containsKey('ACTION')) {
|
||||||
|
final action = response['ACTION'] as String;
|
||||||
|
final changes = response['CHANGES'] as List<dynamic>;
|
||||||
|
final explanation = response['EXPLANATION'] as String? ?? '';
|
||||||
|
|
||||||
|
_addChatMessage(
|
||||||
|
'AI: $explanation',
|
||||||
|
false,
|
||||||
|
action: AIAction(
|
||||||
|
type: action,
|
||||||
|
changes: changes.map((c) => c as Map<String, dynamic>).toList(),
|
||||||
|
isApplied: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_addChatMessage('AI: $response', false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_addChatMessage('❌ Ошибка AI: $e', false);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isAISubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyAIChanges(AIAction action) {
|
||||||
|
for (final change in action.changes) {
|
||||||
|
final actionType = change['action'] as String;
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case 'add':
|
||||||
|
_nodes.add({
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'title': change['title'] ?? '',
|
||||||
|
'level': change['level'] ?? 0,
|
||||||
|
'parentId': change['parentId'],
|
||||||
|
'color': _getColorForLevel(change['level'] ?? 0),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
final index = _nodes.indexWhere((n) => n['id'] == change['id']);
|
||||||
|
if (index != -1) {
|
||||||
|
_nodes[index] = {
|
||||||
|
..._nodes[index],
|
||||||
|
'title': change['title'] ?? _nodes[index]['title'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
final nodesToDelete = <String>{change['id'] as String};
|
||||||
|
_collectChildren(change['id'] as String, nodesToDelete);
|
||||||
|
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем сообщение в чате
|
||||||
|
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
|
||||||
|
if (msgIndex != -1) {
|
||||||
|
setState(() {
|
||||||
|
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
|
||||||
|
action: action.copyWith(isApplied: true),
|
||||||
|
);
|
||||||
|
_nodes = _nodes.toList(); // Триггер rebuild
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Изменения AI применены'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rejectAIChanges(AIAction action) {
|
||||||
|
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
|
||||||
|
if (msgIndex != -1) {
|
||||||
|
setState(() {
|
||||||
|
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
|
||||||
|
action: action.copyWith(isApplied: false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== EDIT FUNCTIONS ==========
|
||||||
|
|
||||||
|
void _addNode({String? parentId}) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _NodeTitleDialog(
|
||||||
|
onAdd: (title) {
|
||||||
|
final parentNode = parentId != null
|
||||||
|
? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_nodes.add({
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'title': title,
|
||||||
|
'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0,
|
||||||
|
'parentId': parentId,
|
||||||
|
'color': _getColorForLevel(
|
||||||
|
parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editNode(String nodeId) {
|
||||||
|
final node = _nodes.firstWhere((n) => n['id'] == nodeId);
|
||||||
|
final controller = TextEditingController(text: node['title'] as String);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Редактировать узел'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Название',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final index = _nodes.indexWhere((n) => n['id'] == nodeId);
|
||||||
|
if (index != -1) {
|
||||||
|
setState(() {
|
||||||
|
_nodes[index] = {..._nodes[index], 'title': controller.text};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Сохранить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteNode(String nodeId) {
|
||||||
|
final nodesToDelete = <String>{nodeId};
|
||||||
|
_collectChildren(nodeId, nodesToDelete);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Удалить узел?'),
|
||||||
|
content: Text(
|
||||||
|
'Удалить "${_nodes.firstWhere((n) => n['id'] == nodeId)['title']}" и все дочерние узлы?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Удалить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collectChildren(String parentId, Set<String> result) {
|
||||||
|
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
|
||||||
|
for (final child in children) {
|
||||||
|
final childId = child['id'] as String;
|
||||||
|
result.add(childId);
|
||||||
|
_collectChildren(childId, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveChanges() async {
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(mindMapsNotifierProvider.notifier);
|
||||||
|
final isAdmin = ref.read(isAdminProvider);
|
||||||
|
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
|
||||||
|
|
||||||
|
// Проверка прав
|
||||||
|
if (!isAdmin && widget.mindMap.ownerEmail != userEmail) {
|
||||||
|
throw Exception('Нет прав для редактирования');
|
||||||
|
}
|
||||||
|
|
||||||
|
final updated = widget.mindMap.copyWith(
|
||||||
|
nodes: _nodes,
|
||||||
|
isMain: _isMain,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.updateMindMap(widget.mindMap.id, updated);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Изменения сохранены'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getColorForLevel(int level) {
|
||||||
|
final colors = [
|
||||||
|
'#2196F3',
|
||||||
|
'#4CAF50',
|
||||||
|
'#FF9800',
|
||||||
|
'#E91E63',
|
||||||
|
'#9C27B0',
|
||||||
|
'#00BCD4'
|
||||||
|
];
|
||||||
|
return colors[level % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _hexToColor(String hex) {
|
||||||
|
hex = hex.replaceAll('#', '');
|
||||||
|
if (hex.length == 6) return Color(int.parse('FF$hex', radix: 16));
|
||||||
|
return const Color(0xFF2196F3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== BUILD ==========
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isAdmin = ref.watch(isAdminProvider);
|
||||||
|
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
|
||||||
|
final canEdit = isAdmin || widget.mindMap.ownerEmail == userEmail;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.mindMap.title),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isMain ? Icons.star : Icons.person,
|
||||||
|
size: 14,
|
||||||
|
color: _isMain ? Colors.amber : Colors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_isMain ? 'Основная' : 'Личная',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _isMain ? Colors.amber : Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canEdit && _isEditing)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'✏️ Редактирование',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
// Toggle edit mode
|
||||||
|
if (canEdit)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isEditing ? Icons.visibility : Icons.edit),
|
||||||
|
onPressed: () => setState(() => _isEditing = !_isEditing),
|
||||||
|
tooltip: _isEditing ? 'Режим просмотра' : 'Режим редактирования',
|
||||||
|
),
|
||||||
|
// AI Chat
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_showChat ? Icons.close : Icons.auto_awesome),
|
||||||
|
onPressed: () => setState(() => _showChat = !_showChat),
|
||||||
|
tooltip: _showChat ? 'Закрыть AI чат' : 'Открыть AI чат',
|
||||||
|
),
|
||||||
|
// Save
|
||||||
|
if (_isEditing)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
onPressed: _saveChanges,
|
||||||
|
tooltip: 'Сохранить изменения',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Mind Map (фон)
|
||||||
|
_nodes.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: rm.MindMapWidget(
|
||||||
|
data: _buildMindMapData(),
|
||||||
|
style: rm.MindMapStyle(
|
||||||
|
layout: rm.MindMapLayout.radial,
|
||||||
|
nodeShape: rm.NodeShape.roundedRectangle,
|
||||||
|
connectionColor: Colors.blue.shade100,
|
||||||
|
connectionWidth: 2.0,
|
||||||
|
),
|
||||||
|
cameraFocus: rm.CameraFocus.fitAll,
|
||||||
|
onNodeTap: _isEditing ? (node) => _editNode(node.id) : null,
|
||||||
|
onNodeLongPress:
|
||||||
|
_isEditing ? (node) => _deleteNode(node.id) : null,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Чат поверх карты
|
||||||
|
if (_showChat) _buildChatOverlay(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// FAB для добавления узлов (в режиме редактирования)
|
||||||
|
floatingActionButton: _isEditing && !_showChat
|
||||||
|
? FloatingActionButton(
|
||||||
|
onPressed: () => _addNode(),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
tooltip: 'Добавить корневой узел',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatOverlay() {
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: MediaQuery.of(context).size.height * 0.45,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Градиентная полоска сверху
|
||||||
|
Container(
|
||||||
|
height: 4,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.purple, Colors.deepPurple],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Панель чата
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
color: Colors.purple.shade800,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.auto_awesome,
|
||||||
|
color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'AI Ассистент',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${_chatMessages.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
Expanded(
|
||||||
|
child: _chatMessages.isEmpty
|
||||||
|
? _buildChatEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
itemCount: _chatMessages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildChatMessage(_chatMessages[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Input
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxHeight: 40,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _chatController,
|
||||||
|
maxLines: null,
|
||||||
|
minLines: 1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'AI помощь...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 2),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (text) => _sendToAI(text),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.purple,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isAISubmitting
|
||||||
|
? null
|
||||||
|
: () => _sendToAI(_chatController.text),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: _isAISubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send,
|
||||||
|
color: Colors.white, size: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Mind map пуста',
|
||||||
|
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
if (_isEditing) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _addNode(),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Добавить первый узел'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.auto_awesome, size: 40, color: Colors.purple[300]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'AI Ассистент',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Напишите "добавь узлы" или\n"предложи улучшения"',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatMessage(ChatMessage msg) {
|
||||||
|
return Align(
|
||||||
|
alignment: msg.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: msg.isUser ? Colors.blue : Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
msg.isUser ? msg.text : msg.text.replaceFirst('AI: ', ''),
|
||||||
|
style: TextStyle(
|
||||||
|
color: msg.isUser ? Colors.white : Colors.black87,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (msg.action != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildAIActionButtons(msg.action!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAIActionButtons(AIAction action) {
|
||||||
|
if (action.isApplied) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: Colors.green, size: 16),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text('Применено', style: TextStyle(color: Colors.green)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _applyAIChanges(action),
|
||||||
|
icon: const Icon(Icons.check, size: 18),
|
||||||
|
label: const Text('Применить'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.green,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _rejectAIChanges(action),
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
label: const Text('Отклонить'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rm.MindMapData _buildMindMapData() {
|
||||||
|
final rootNodes = _nodes.where((n) => n['parentId'] == null).toList();
|
||||||
|
|
||||||
|
if (rootNodes.isEmpty) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: 'empty', title: widget.mindMap.title, children: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
final root = rootNodes.first;
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: root['id'] as String,
|
||||||
|
title: root['title'] as String,
|
||||||
|
color: _hexToColor(root['color'] as String? ?? '#2196F3'),
|
||||||
|
children: _buildChildren(root['id'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<rm.MindMapData> _buildChildren(String parentId) {
|
||||||
|
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
|
||||||
|
return children.map((node) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: node['id'] as String,
|
||||||
|
title: node['title'] as String,
|
||||||
|
color: _hexToColor(node['color'] as String? ?? '#4CAF50'),
|
||||||
|
children: _buildChildren(node['id'] as String),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MODELS ==========
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
final String text;
|
||||||
|
final bool isUser;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final AIAction? action;
|
||||||
|
|
||||||
|
ChatMessage({
|
||||||
|
required this.text,
|
||||||
|
required this.isUser,
|
||||||
|
required this.timestamp,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatMessage copyWith({String? text, bool? isUser, AIAction? action}) {
|
||||||
|
return ChatMessage(
|
||||||
|
text: text ?? this.text,
|
||||||
|
isUser: isUser ?? this.isUser,
|
||||||
|
timestamp: timestamp,
|
||||||
|
action: action ?? this.action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AIAction {
|
||||||
|
final String type;
|
||||||
|
final List<Map<String, dynamic>> changes;
|
||||||
|
final bool isApplied;
|
||||||
|
|
||||||
|
AIAction({
|
||||||
|
required this.type,
|
||||||
|
required this.changes,
|
||||||
|
this.isApplied = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
AIAction copyWith({bool? isApplied}) {
|
||||||
|
return AIAction(
|
||||||
|
type: type,
|
||||||
|
changes: changes,
|
||||||
|
isApplied: isApplied ?? this.isApplied,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NodeTitleDialog extends StatefulWidget {
|
||||||
|
final Function(String) onAdd;
|
||||||
|
|
||||||
|
const _NodeTitleDialog({required this.onAdd});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NodeTitleDialog> createState() => _NodeTitleDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NodeTitleDialogState extends State<_NodeTitleDialog> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Название узла'),
|
||||||
|
content: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Введите название',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (text) {
|
||||||
|
if (text.isNotEmpty) widget.onAdd(text);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_controller.text.isNotEmpty) {
|
||||||
|
widget.onAdd(_controller.text);
|
||||||
|
}
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Добавить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+278
-270
@@ -1,57 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../providers/mindmap_provider.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import '../models/mind_map_model.dart';
|
import '../providers/mind_maps_provider.dart';
|
||||||
|
import '../models/mindmap_model.dart';
|
||||||
|
import '../core/admin_access_service.dart';
|
||||||
|
import 'mind_map_viewer_screen.dart';
|
||||||
|
import 'mind_map_editor_screen.dart';
|
||||||
|
import 'mind_map_interactive_screen.dart';
|
||||||
|
|
||||||
class MindMapScreen extends ConsumerStatefulWidget {
|
class MindMapScreen extends ConsumerWidget {
|
||||||
const MindMapScreen({super.key});
|
const MindMapScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<MindMapScreen> createState() => _MindMapScreenState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final mindMapsAsync = ref.watch(mindMapsProvider);
|
||||||
|
final isAdmin = ref.watch(isAdminProvider);
|
||||||
class _MindMapScreenState extends ConsumerState<MindMapScreen> {
|
final currentUserEmail = FirebaseAuth.instance.currentUser?.email ?? '';
|
||||||
bool _isEditing = false;
|
|
||||||
bool _isGenerating = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final projectId = 'default'; // TODO: Получить текущий проект
|
|
||||||
final mindMapState = ref.watch(mindMapProvider(projectId));
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Mind-карта'),
|
title: const Text('Mind Maps'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (isAdmin)
|
||||||
icon: Icon(_isEditing ? Icons.check : Icons.edit),
|
IconButton(
|
||||||
onPressed: () {
|
icon: const Icon(Icons.admin_panel_settings),
|
||||||
setState(() {
|
onPressed: () => _showAdminOptions(context),
|
||||||
_isEditing = !_isEditing;
|
tooltip: 'Админ панель',
|
||||||
});
|
),
|
||||||
},
|
|
||||||
tooltip: _isEditing ? 'Готово' : 'Редактировать',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.auto_awesome),
|
|
||||||
onPressed: _isGenerating ? null : () => _generateWithAI(projectId),
|
|
||||||
tooltip: 'AI Генерация',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
onPressed: () => _showAddNodeDialog(context, projectId, null),
|
|
||||||
tooltip: 'Добавить узел',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: mindMapState.when(
|
body: mindMapsAsync.when(
|
||||||
data: (mindMapData) {
|
data: (mindMaps) =>
|
||||||
if (mindMapData.nodes.isEmpty) {
|
_buildContent(context, mindMaps, isAdmin, currentUserEmail, ref),
|
||||||
return _buildEmptyState(context, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildSimpleMindMap(mindMapData);
|
|
||||||
},
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -62,121 +42,185 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
|
|||||||
Text('Ошибка: $error'),
|
Text('Ошибка: $error'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.refresh(mindMapProvider(projectId)),
|
onPressed: () => ref.invalidate(mindMapsProvider),
|
||||||
child: const Text('Повторить'),
|
child: const Text('Повторить'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: _isEditing
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
? FloatingActionButton.extended(
|
onPressed: () => _showCreateOptions(context, ref),
|
||||||
onPressed: () => _showAddNodeDialog(context, projectId, null),
|
icon: const Icon(Icons.add),
|
||||||
icon: const Icon(Icons.add),
|
label: const Text('Создать'),
|
||||||
label: const Text('Добавить узел'),
|
),
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Простое отображение дерева (замена GraphView)
|
Widget _buildContent(
|
||||||
Widget _buildSimpleMindMap(MindMapData mindMapData) {
|
BuildContext context,
|
||||||
return ListView.builder(
|
List<MindMapModel> mindMaps,
|
||||||
|
bool isAdmin,
|
||||||
|
String currentUserEmail,
|
||||||
|
WidgetRef ref,
|
||||||
|
) {
|
||||||
|
// Разделяем на основные и личные
|
||||||
|
final mainMaps = mindMaps.where((m) => m.isMain).toList();
|
||||||
|
final personalMaps = mindMaps.where((m) => !m.isMain).toList();
|
||||||
|
|
||||||
|
if (mindMaps.isEmpty) {
|
||||||
|
return _buildEmptyState(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: mindMapData.nodes.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final node = mindMapData.nodes[index];
|
|
||||||
return _buildTreeNodeWidget(node, mindMapData);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTreeNodeWidget(MindMapNode node, MindMapData mindMapData) {
|
|
||||||
final children = mindMapData.getChildren(node.id);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Основные карты
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
if (mainMaps.isNotEmpty) ...[
|
||||||
margin: EdgeInsets.only(left: node.level * 20.0),
|
_buildSectionHeader('Основные', Icons.star, Colors.amber),
|
||||||
decoration: BoxDecoration(
|
...mainMaps.map((map) =>
|
||||||
color: _hexToColor(node.color),
|
_buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
const SizedBox(height: 16),
|
||||||
),
|
],
|
||||||
child: Row(
|
|
||||||
children: [
|
// Личные карты
|
||||||
Icon(
|
if (personalMaps.isNotEmpty) ...[
|
||||||
_getIconData(node.icon),
|
_buildSectionHeader('Личные', Icons.person, Colors.blue),
|
||||||
color: Colors.white,
|
...personalMaps.map((map) =>
|
||||||
size: 20,
|
_buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)),
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
node.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isEditing)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete, color: Colors.white),
|
|
||||||
onPressed: () => _confirmDeleteNode(node),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (children.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...children.map((child) => _buildTreeNodeWidget(child, mindMapData)),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, String projectId) {
|
Widget _buildSectionHeader(String title, IconData icon, Color color) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMindMapCard(
|
||||||
|
BuildContext context,
|
||||||
|
MindMapModel mindMap,
|
||||||
|
bool isAdmin,
|
||||||
|
String currentUserEmail,
|
||||||
|
WidgetRef ref,
|
||||||
|
) {
|
||||||
|
final isOwner = mindMap.ownerEmail == currentUserEmail;
|
||||||
|
final canDelete = isAdmin || isOwner;
|
||||||
|
final canEdit = isAdmin || isOwner;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: mindMap.isMain ? Colors.amber : Colors.blue,
|
||||||
|
child: Icon(
|
||||||
|
mindMap.isMain ? Icons.star : Icons.account_tree,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
mindMap.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (mindMap.description.isNotEmpty)
|
||||||
|
Text(mindMap.description,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Владелец: ${mindMap.ownerEmail}'),
|
||||||
|
Text(
|
||||||
|
'${mindMap.nodeCount} узлов • ${_formatDate(mindMap.createdAt)}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
onSelected: (value) => _handleAction(context, value, mindMap, ref),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'view',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.visibility, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Открыть'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canEdit)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'edit',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.edit, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Редактировать'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canDelete)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Удалить', style: TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => _openMindMap(context, mindMap),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
|
||||||
Icons.account_tree,
|
|
||||||
size: 100,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Text(
|
||||||
'Mind-карта пуста',
|
'Нет mind карт',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Создайте структуру проекта',
|
'Создайте первую карту вручную или с помощью AI',
|
||||||
style: TextStyle(color: Colors.grey),
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _showAddNodeDialog(context, projectId, null),
|
onPressed: () => _showCreateManualDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Добавить вручную'),
|
label: const Text('Вручную'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
OutlinedButton.icon(
|
FilledButton.icon(
|
||||||
onPressed:
|
onPressed: () => _showCreateAIDialog(context, ref),
|
||||||
_isGenerating ? null : () => _generateWithAI(projectId),
|
icon: const Icon(Icons.auto_awesome),
|
||||||
icon: _isGenerating
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.auto_awesome),
|
|
||||||
label: const Text('AI Генерация'),
|
label: const Text('AI Генерация'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -186,159 +230,133 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _generateWithAI(String projectId) async {
|
void _showCreateOptions(BuildContext context, WidgetRef ref) {
|
||||||
setState(() {
|
showModalBottomSheet(
|
||||||
_isGenerating = true;
|
context: context,
|
||||||
});
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add, color: Colors.blue),
|
||||||
|
title: const Text('Создать вручную'),
|
||||||
|
subtitle: const Text('Пустая mind map'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showCreateManualDialog(context, ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.auto_awesome, color: Colors.purple),
|
||||||
|
title: const Text('AI Генерация'),
|
||||||
|
subtitle: const Text('С помощью DeepSeek'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showCreateAIDialog(context, ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
void _showCreateManualDialog(BuildContext context, WidgetRef ref) {
|
||||||
final projectName = await _showProjectNameDialog();
|
Navigator.push(
|
||||||
if (projectName == null || projectName.isEmpty) return;
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const MindMapEditorScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final projectDescription = await _showProjectDescriptionDialog();
|
void _showCreateAIDialog(BuildContext context, WidgetRef ref) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const MindMapEditorScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await ref.read(mindMapProvider(projectId).notifier).generateMindMapWithAI(
|
void _handleAction(
|
||||||
projectName: projectName,
|
BuildContext context,
|
||||||
description: projectDescription ?? '',
|
String action,
|
||||||
);
|
MindMapModel mindMap,
|
||||||
|
WidgetRef ref,
|
||||||
if (mounted) {
|
) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
switch (action) {
|
||||||
const SnackBar(
|
case 'view':
|
||||||
content: Text('✅ Mind-карта сгенерирована!'),
|
_openMindMap(context, mindMap);
|
||||||
backgroundColor: Colors.green,
|
break;
|
||||||
),
|
case 'edit':
|
||||||
);
|
_editMindMap(context, mindMap, ref);
|
||||||
}
|
break;
|
||||||
} catch (e) {
|
case 'delete':
|
||||||
if (mounted) {
|
_confirmDelete(context, mindMap, ref);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
break;
|
||||||
SnackBar(
|
|
||||||
content: Text('❌ Ошибка: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
_isGenerating = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _showProjectNameDialog() {
|
void _openMindMap(BuildContext context, MindMapModel mindMap) {
|
||||||
final controller = TextEditingController();
|
Navigator.push(
|
||||||
return showDialog<String>(
|
context,
|
||||||
context: context,
|
MaterialPageRoute(
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => MindMapInteractiveScreen(mindMap: mindMap),
|
||||||
title: const Text('Название проекта'),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Введите название',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
autofocus: true,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Отмена'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, controller.text),
|
|
||||||
child: const Text('Генерировать'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _showProjectDescriptionDialog() {
|
void _editMindMap(BuildContext context, MindMapModel mindMap, WidgetRef ref) {
|
||||||
final controller = TextEditingController();
|
Navigator.push(
|
||||||
return showDialog<String>(
|
context,
|
||||||
context: context,
|
MaterialPageRoute(
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => MindMapEditorScreen(mindMap: mindMap),
|
||||||
title: const Text('Описание проекта'),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Описание (опционально)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, ''),
|
|
||||||
child: const Text('Пропустить'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context, controller.text),
|
|
||||||
child: const Text('Генерировать'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddNodeDialog(
|
void _confirmDelete(
|
||||||
BuildContext context, String projectId, String? parentId) {
|
BuildContext context, MindMapModel mindMap, WidgetRef ref) {
|
||||||
final titleController = TextEditingController();
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Добавить узел'),
|
title: const Text('Удалить mind map?'),
|
||||||
content: TextField(
|
content: Text(
|
||||||
controller: titleController,
|
'"${mindMap.title}" будет удалена навсегда. Это действие нельзя отменить.'),
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Название',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
autofocus: true,
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Отмена'),
|
child: const Text('Отмена'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (titleController.text.isEmpty) return;
|
try {
|
||||||
|
await ref
|
||||||
|
.read(mindMapsNotifierProvider.notifier)
|
||||||
|
.deleteMindMap(mindMap.id, mindMap.ownerEmail);
|
||||||
|
|
||||||
await ref.read(mindMapProvider(projectId).notifier).addNode(
|
if (context.mounted) {
|
||||||
title: titleController.text,
|
Navigator.pop(context);
|
||||||
parentId: parentId,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Mind map удалена'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (context.mounted) {
|
} catch (e) {
|
||||||
Navigator.pop(context);
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Добавить'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _confirmDeleteNode(MindMapNode node) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Удалить узел?'),
|
|
||||||
content: const Text('Все дочерние узлы также будут удалены'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Отмена'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
// TODO: Удалить узел
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Удалить'),
|
child: const Text('Удалить'),
|
||||||
),
|
),
|
||||||
@@ -347,26 +365,16 @@ class _MindMapScreenState extends ConsumerState<MindMapScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _hexToColor(String hex) {
|
void _showAdminOptions(BuildContext context) {
|
||||||
hex = hex.replaceAll('#', '');
|
// TODO: Открыть админ панель
|
||||||
if (hex.length == 6) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
return Color(int.parse('0xFF$hex'));
|
const SnackBar(content: Text('Админ панель в разработке')),
|
||||||
}
|
);
|
||||||
return Colors.blue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getIconData(String iconName) {
|
String _formatDate(DateTime date) {
|
||||||
switch (iconName) {
|
return '${date.day.toString().padLeft(2, '0')}.'
|
||||||
case 'folder':
|
'${date.month.toString().padLeft(2, '0')}.'
|
||||||
return Icons.folder;
|
'${date.year}';
|
||||||
case 'task':
|
|
||||||
return Icons.task;
|
|
||||||
case 'star':
|
|
||||||
return Icons.star;
|
|
||||||
case 'flag':
|
|
||||||
return Icons.flag;
|
|
||||||
default:
|
|
||||||
return Icons.circle;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MindMapSettingsScreen extends ConsumerStatefulWidget {
|
||||||
|
final String projectId;
|
||||||
|
|
||||||
|
const MindMapSettingsScreen({super.key, required this.projectId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MindMapSettingsScreen> createState() =>
|
||||||
|
_MindMapSettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MindMapSettingsScreenState extends ConsumerState<MindMapSettingsScreen> {
|
||||||
|
rm.MindMapLayout _defaultLayout = rm.MindMapLayout.radial;
|
||||||
|
rm.NodeShape _defaultShape = rm.NodeShape.roundedRectangle;
|
||||||
|
rm.CameraFocus _defaultCamera = rm.CameraFocus.fitAll;
|
||||||
|
bool _autoExpandNodes = true;
|
||||||
|
bool _showConnections = true;
|
||||||
|
bool _enableShadows = true;
|
||||||
|
double _connectionWidth = 2.0;
|
||||||
|
Color _connectionColor = const Color(0xFFB0BEC5);
|
||||||
|
List<Color> _nodeColors = const [
|
||||||
|
Color(0xFF2196F3),
|
||||||
|
Color(0xFF4CAF50),
|
||||||
|
Color(0xFFFF9800),
|
||||||
|
Color(0xFFE91E63),
|
||||||
|
Color(0xFF9C27B0),
|
||||||
|
];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
setState(() {
|
||||||
|
_defaultLayout = rm.MindMapLayout.values[
|
||||||
|
prefs.getInt('${widget.projectId}_layout') ?? 0];
|
||||||
|
_defaultShape = rm.NodeShape.values[
|
||||||
|
prefs.getInt('${widget.projectId}_shape') ?? 0];
|
||||||
|
_defaultCamera = rm.CameraFocus.values[
|
||||||
|
prefs.getInt('${widget.projectId}_camera') ?? 0];
|
||||||
|
_autoExpandNodes =
|
||||||
|
prefs.getBool('${widget.projectId}_auto_expand') ?? true;
|
||||||
|
_showConnections =
|
||||||
|
prefs.getBool('${widget.projectId}_show_connections') ?? true;
|
||||||
|
_enableShadows =
|
||||||
|
prefs.getBool('${widget.projectId}_shadows') ?? true;
|
||||||
|
_connectionWidth =
|
||||||
|
prefs.getDouble('${widget.projectId}_conn_width') ?? 2.0;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(
|
||||||
|
'${widget.projectId}_layout', _defaultLayout.index);
|
||||||
|
await prefs.setInt('${widget.projectId}_shape', _defaultShape.index);
|
||||||
|
await prefs.setInt(
|
||||||
|
'${widget.projectId}_camera', _defaultCamera.index);
|
||||||
|
await prefs.setBool(
|
||||||
|
'${widget.projectId}_auto_expand', _autoExpandNodes);
|
||||||
|
await prefs.setBool(
|
||||||
|
'${widget.projectId}_show_connections', _showConnections);
|
||||||
|
await prefs.setBool('${widget.projectId}_shadows', _enableShadows);
|
||||||
|
await prefs.setDouble(
|
||||||
|
'${widget.projectId}_conn_width', _connectionWidth);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Настройки сохранены'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('❌ Ошибка: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportMindMap() async {
|
||||||
|
// TODO: Экспорт mind map в JSON файл
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('📤 Экспорт в разработке'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importMindMap() async {
|
||||||
|
// TODO: Импорт mind map из JSON файла
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('📥 Импорт в разработке'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Настройки Mind Map'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
onPressed: _saveSettings,
|
||||||
|
tooltip: 'Сохранить',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildSection(
|
||||||
|
'Layout',
|
||||||
|
[
|
||||||
|
_buildDropdown<rm.MindMapLayout>(
|
||||||
|
'Layout по умолчанию',
|
||||||
|
_defaultLayout,
|
||||||
|
rm.MindMapLayout.values,
|
||||||
|
(value) => setState(() => _defaultLayout = value!),
|
||||||
|
labels: const {
|
||||||
|
rm.MindMapLayout.radial: 'Радиальный',
|
||||||
|
rm.MindMapLayout.right: 'Вправо',
|
||||||
|
rm.MindMapLayout.left: 'Влево',
|
||||||
|
rm.MindMapLayout.horizontal: 'Горизонтальный',
|
||||||
|
rm.MindMapLayout.vertical: 'Вертикальный',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildDropdown<rm.NodeShape>(
|
||||||
|
'Форма узлов',
|
||||||
|
_defaultShape,
|
||||||
|
rm.NodeShape.values,
|
||||||
|
(value) => setState(() => _defaultShape = value!),
|
||||||
|
labels: const {
|
||||||
|
rm.NodeShape.roundedRectangle: 'Скругленный прямоугольник',
|
||||||
|
rm.NodeShape.circle: 'Круг',
|
||||||
|
rm.NodeShape.diamond: 'Ромб',
|
||||||
|
rm.NodeShape.hexagon: 'Шестиугольник',
|
||||||
|
rm.NodeShape.ellipse: 'Эллипс',
|
||||||
|
rm.NodeShape.rectangle: 'Прямоугольник',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildDropdown<rm.CameraFocus>(
|
||||||
|
'Фокус камеры',
|
||||||
|
_defaultCamera,
|
||||||
|
rm.CameraFocus.values,
|
||||||
|
(value) => setState(() => _defaultCamera = value!),
|
||||||
|
labels: const {
|
||||||
|
rm.CameraFocus.fitAll: 'Вместить всё',
|
||||||
|
rm.CameraFocus.rootNode: 'Корневой узел',
|
||||||
|
rm.CameraFocus.center: 'Центр',
|
||||||
|
rm.CameraFocus.firstLeaf: 'Первый лист',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSection(
|
||||||
|
'Поведение',
|
||||||
|
[
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Авто-раскрытие узлов'),
|
||||||
|
subtitle: const Text('Раскрывать все узлы при загрузке'),
|
||||||
|
value: _autoExpandNodes,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _autoExpandNodes = value),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Показывать соединения'),
|
||||||
|
value: _showConnections,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _showConnections = value),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Тени узлов'),
|
||||||
|
value: _enableShadows,
|
||||||
|
onChanged: (value) => setState(() => _enableShadows = value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSection(
|
||||||
|
'Соединительные линии',
|
||||||
|
[
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Толщина линий'),
|
||||||
|
subtitle: Text('${_connectionWidth.toStringAsFixed(1)}px'),
|
||||||
|
trailing: SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: Slider(
|
||||||
|
value: _connectionWidth,
|
||||||
|
min: 1.0,
|
||||||
|
max: 5.0,
|
||||||
|
divisions: 8,
|
||||||
|
label: _connectionWidth.toStringAsFixed(1),
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _connectionWidth = value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Цвет линий'),
|
||||||
|
trailing: GestureDetector(
|
||||||
|
onTap: () => _showColorPicker('connection'),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _connectionColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSection(
|
||||||
|
'Цвета узлов',
|
||||||
|
[
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(_nodeColors.length, (index) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showColorPicker('node_$index'),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _nodeColors[index],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Нажмите на цвет чтобы изменить',
|
||||||
|
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildSection(
|
||||||
|
'Экспорт / Импорт',
|
||||||
|
[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _exportMindMap,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Экспорт JSON'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _importMindMap,
|
||||||
|
icon: const Icon(Icons.upload),
|
||||||
|
label: const Text('Импорт JSON'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildResetButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection(String title, List<Widget> children) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdown<T>(
|
||||||
|
String label,
|
||||||
|
T value,
|
||||||
|
List<T> items,
|
||||||
|
void Function(T?) onChanged, {
|
||||||
|
Map<T, String>? labels,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: DropdownButtonFormField<T>(
|
||||||
|
value: value,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: items.map((item) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: item,
|
||||||
|
child: Text(labels?[item] ?? item.toString().split('.').last),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showColorPicker(String type) async {
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF2196F3),
|
||||||
|
const Color(0xFF4CAF50),
|
||||||
|
const Color(0xFFFF9800),
|
||||||
|
const Color(0xFFE91E63),
|
||||||
|
const Color(0xFF9C27B0),
|
||||||
|
const Color(0xFF00BCD4),
|
||||||
|
const Color(0xFFF44336),
|
||||||
|
const Color(0xFF795548),
|
||||||
|
const Color(0xFF607D8B),
|
||||||
|
const Color(0xFFB0BEC5),
|
||||||
|
];
|
||||||
|
|
||||||
|
final selected = await showDialog<Color>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Выберите цвет'),
|
||||||
|
content: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: colors.map((color) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.pop(context, color),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
setState(() {
|
||||||
|
if (type == 'connection') {
|
||||||
|
_connectionColor = selected;
|
||||||
|
} else if (type.startsWith('node_')) {
|
||||||
|
final index = int.parse(type.split('_')[1]);
|
||||||
|
_nodeColors[index] = selected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResetButton() {
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_defaultLayout = rm.MindMapLayout.radial;
|
||||||
|
_defaultShape = rm.NodeShape.roundedRectangle;
|
||||||
|
_defaultCamera = rm.CameraFocus.fitAll;
|
||||||
|
_autoExpandNodes = true;
|
||||||
|
_showConnections = true;
|
||||||
|
_enableShadows = true;
|
||||||
|
_connectionWidth = 2.0;
|
||||||
|
_connectionColor = const Color(0xFFB0BEC5);
|
||||||
|
_nodeColors = const [
|
||||||
|
Color(0xFF2196F3),
|
||||||
|
Color(0xFF4CAF50),
|
||||||
|
Color(0xFFFF9800),
|
||||||
|
Color(0xFFE91E63),
|
||||||
|
Color(0xFF9C27B0),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.restore, color: Colors.red),
|
||||||
|
label: const Text('Сбросить настройки',
|
||||||
|
style: TextStyle(color: Colors.red)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
|
||||||
|
import '../models/mindmap_model.dart';
|
||||||
|
|
||||||
|
class MindMapViewerScreen extends StatefulWidget {
|
||||||
|
final MindMapModel mindMap;
|
||||||
|
|
||||||
|
const MindMapViewerScreen({super.key, required this.mindMap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MindMapViewerScreen> createState() => _MindMapViewerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MindMapViewerScreenState extends State<MindMapViewerScreen> {
|
||||||
|
rm.MindMapLayout _currentLayout = rm.MindMapLayout.radial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.mindMap.title),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<rm.MindMapLayout>(
|
||||||
|
icon: const Icon(Icons.account_tree),
|
||||||
|
tooltip: 'Тип layout',
|
||||||
|
onSelected: (layout) => setState(() => _currentLayout = layout),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
_popupMenuItem(
|
||||||
|
rm.MindMapLayout.radial, 'Радиальный', Icons.circle_outlined),
|
||||||
|
_popupMenuItem(
|
||||||
|
rm.MindMapLayout.right, 'Вправо', Icons.arrow_forward),
|
||||||
|
_popupMenuItem(rm.MindMapLayout.left, 'Влево', Icons.arrow_back),
|
||||||
|
_popupMenuItem(rm.MindMapLayout.horizontal, 'Горизонтальный',
|
||||||
|
Icons.swap_horiz),
|
||||||
|
_popupMenuItem(
|
||||||
|
rm.MindMapLayout.vertical, 'Вертикальный', Icons.swap_vert),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: widget.mindMap.nodes.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: rm.MindMapWidget(
|
||||||
|
data: _buildMindMapData(),
|
||||||
|
style: rm.MindMapStyle(
|
||||||
|
layout: _currentLayout,
|
||||||
|
nodeShape: rm.NodeShape.roundedRectangle,
|
||||||
|
// Используем стандартные узлы (без кастомизации)
|
||||||
|
connectionColor: Colors.blue.shade100,
|
||||||
|
connectionWidth: 2.0,
|
||||||
|
),
|
||||||
|
cameraFocus: rm.CameraFocus.fitAll,
|
||||||
|
onNodeTap: (node) {
|
||||||
|
// TODO: Редактирование узла
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rm.MindMapData _buildMindMapData() {
|
||||||
|
// Находим корневой узел (level 0)
|
||||||
|
final rootNodes =
|
||||||
|
widget.mindMap.nodes.where((n) => n['level'] == 0).toList();
|
||||||
|
|
||||||
|
if (rootNodes.isEmpty) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: 'empty',
|
||||||
|
title: widget.mindMap.title,
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final root = rootNodes.first;
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: root['id'] as String,
|
||||||
|
title: root['title'] as String,
|
||||||
|
color: _hexToColor(root['color'] as String? ?? '#2196F3'),
|
||||||
|
children: _buildChildren(root['id'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<rm.MindMapData> _buildChildren(String parentId) {
|
||||||
|
final children =
|
||||||
|
widget.mindMap.nodes.where((n) => n['parentId'] == parentId).toList();
|
||||||
|
|
||||||
|
return children.map((node) {
|
||||||
|
return rm.MindMapData(
|
||||||
|
id: node['id'] as String,
|
||||||
|
title: node['title'] as String,
|
||||||
|
color: _hexToColor(node['color'] as String? ?? '#4CAF50'),
|
||||||
|
children: _buildChildren(node['id'] as String),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCustomNode(
|
||||||
|
rm.MindMapData node,
|
||||||
|
VoidCallback onTap,
|
||||||
|
VoidCallback onLongPress,
|
||||||
|
bool isSelected,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: node.color ?? const Color(0xFF2196F3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? Border.all(color: Colors.white, width: 3) : null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.15),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, color: Colors.white70, size: 12),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
|
child: Text(
|
||||||
|
node.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (node.children.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.keyboard_arrow_down, color: Colors.white70, size: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Mind map пуста',
|
||||||
|
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Добавьте узлы для начала работы',
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupMenuItem<rm.MindMapLayout> _popupMenuItem(
|
||||||
|
rm.MindMapLayout layout,
|
||||||
|
String title,
|
||||||
|
IconData icon,
|
||||||
|
) {
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: layout,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(title),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _hexToColor(String hex) {
|
||||||
|
hex = hex.replaceAll('#', '');
|
||||||
|
if (hex.length == 6) {
|
||||||
|
return Color(int.parse('FF$hex', radix: 16));
|
||||||
|
}
|
||||||
|
return const Color(0xFF2196F3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
|
bool _isEditing = false;
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
String? _photoUrl;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProfile() async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
_nameController.text = user.displayName ?? '';
|
||||||
|
_photoUrl = user.photoURL;
|
||||||
|
}
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProfile() async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
if (user != null) {
|
||||||
|
await user.updateDisplayName(_nameController.text);
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.collection('users')
|
||||||
|
.doc(user.uid)
|
||||||
|
.update({
|
||||||
|
'displayName': _nameController.text,
|
||||||
|
'updatedAt': Timestamp.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() => _isEditing = false);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('✅ Профиль сохранён'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Профиль'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_isEditing ? Icons.check : Icons.edit),
|
||||||
|
onPressed: () {
|
||||||
|
if (_isEditing) {
|
||||||
|
_saveProfile();
|
||||||
|
} else {
|
||||||
|
setState(() => _isEditing = true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: _isEditing ? 'Сохранить' : 'Редактировать',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Аватар
|
||||||
|
Center(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 60,
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
backgroundImage: _photoUrl != null
|
||||||
|
? NetworkImage(_photoUrl!)
|
||||||
|
: null,
|
||||||
|
child: _photoUrl == null
|
||||||
|
? Text(
|
||||||
|
user?.displayName?.substring(0, 1).toUpperCase() ??
|
||||||
|
'U',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 40,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (_isEditing)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.camera_alt,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Имя
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
enabled: _isEditing,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Имя',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Email
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.email),
|
||||||
|
title: const Text('Email'),
|
||||||
|
subtitle: Text(user?.email ?? 'Не указан'),
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
|
||||||
|
// UID
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.fingerprint),
|
||||||
|
title: const Text('User ID'),
|
||||||
|
subtitle: Text(user?.uid ?? 'Неизвестно'),
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 32),
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
_buildStatCard(
|
||||||
|
'Статистика',
|
||||||
|
[
|
||||||
|
_buildStatRow('Задач создано', '12'),
|
||||||
|
_buildStatRow('Mind-карт', '5'),
|
||||||
|
_buildStatRow('Будильников', '3'),
|
||||||
|
_buildStatRow('Дней в приложении', '30'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Кнопка выхода
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await FirebaseAuth.instance.signOut();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout, color: Colors.red),
|
||||||
|
label: const Text('Выйти', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(String title, List<Widget> children) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
TaskPriority _priority = TaskPriority.medium;
|
TaskPriority _priority = TaskPriority.medium;
|
||||||
DateTime? _dueDate;
|
DateTime? _dueDate;
|
||||||
int _estimatedHours = 0;
|
int _estimatedHours = 0;
|
||||||
List<AlarmData> _alarms = [];
|
final List<AlarmData> _alarms = [];
|
||||||
bool _isGenerating = false;
|
bool _isGenerating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -267,7 +267,7 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
|
|||||||
|
|
||||||
// Приоритет
|
// Приоритет
|
||||||
DropdownButtonFormField<TaskPriority>(
|
DropdownButtonFormField<TaskPriority>(
|
||||||
value: _priority,
|
initialValue: _priority,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Приоритет',
|
labelText: 'Приоритет',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Карточка статистики
|
||||||
|
class StatCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const StatCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color ?? Colors.blue),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color ?? Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пустое состояние
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
const EmptyState({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 80, color: Colors.grey[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузочный экран
|
||||||
|
class LoadingScreen extends StatelessWidget {
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const LoadingScreen({super.key, this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message!,
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Экран ошибки
|
||||||
|
class ErrorScreen extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
const ErrorScreen({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Ошибка',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
if (onRetry != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Повторить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1008,6 +1008,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.5.0"
|
||||||
|
reactive_mind_map:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: reactive_mind_map
|
||||||
|
sha256: ee7a09c48fc3cd593c8539fee8e4229bf26229580aad3ee35a235c1523dabee8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_staggered_grid_view: ^0.7.0
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
syncfusion_flutter_gauges: ^25.1.38
|
syncfusion_flutter_gauges: ^25.1.38
|
||||||
|
|
||||||
|
# Mind Maps
|
||||||
|
reactive_mind_map: ^1.0.3
|
||||||
graphview: ^1.2.0
|
graphview: ^1.2.0
|
||||||
flutter_mind_map: ^1.0.23
|
flutter_mind_map: ^1.0.23
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Nanaxe Server Configuration
|
||||||
|
# Copy this file to .env and update the values
|
||||||
|
|
||||||
|
# Domain configuration
|
||||||
|
GITEA_DOMAIN=your-domain.com
|
||||||
|
|
||||||
|
# Gitea Configuration
|
||||||
|
GITEA_TOKEN=your_gitea_api_token_here
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
|
# WebDAV (optional)
|
||||||
|
WEBDAV_USER=admin
|
||||||
|
WEBDAV_PASSWORD=secure_password_here
|
||||||
|
|
||||||
|
# Database (change in production!)
|
||||||
|
POSTGRES_PASSWORD=change_this_password
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_PORT=8080
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# 🚀 Развёртывание Nanaxe Server на Proxmox VPS
|
||||||
|
|
||||||
|
## 📋 Содержание
|
||||||
|
1. [Подготовка VPS](#подготовка-vps)
|
||||||
|
2. [Установка Docker](#установка-docker)
|
||||||
|
3. [Настройка сервера](#настройка-сервера)
|
||||||
|
4. [Запуск](#запуск)
|
||||||
|
5. [Настройка домена и SSL](#настройка-домена-и-ssl)
|
||||||
|
6. [Подключение Flutter-приложения](#подключение-flutter-приложения)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подготовка VPS
|
||||||
|
|
||||||
|
### 1. Создание LXC контейнера в Proxmox
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На хосте Proxmox создайте контейнер
|
||||||
|
pct create 100 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \
|
||||||
|
--hostname nanaxe-server \
|
||||||
|
--memory 2048 \
|
||||||
|
--cores 2 \
|
||||||
|
--rootfs local-lvm:10 \
|
||||||
|
--net0 name=eth0,bridge=vmbr0,ip=dhcp
|
||||||
|
|
||||||
|
# Запустите контейнер
|
||||||
|
pct start 100
|
||||||
|
|
||||||
|
# Войдите в контейнер
|
||||||
|
pct enter 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Обновление системы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
apt install -y curl wget git nano htop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
|
||||||
|
# Добавление пользователя в группу docker
|
||||||
|
usermod -aG docker $USER
|
||||||
|
|
||||||
|
# Установка Docker Compose
|
||||||
|
apt install -y docker-compose-plugin
|
||||||
|
|
||||||
|
# Проверка
|
||||||
|
docker --version
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройка сервера
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
git clone <your-repo-url> nanaxe-server
|
||||||
|
cd nanaxe-server/server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Настройка переменных окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Заполните файл:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Domain configuration
|
||||||
|
GITEA_DOMAIN=your-domain.com
|
||||||
|
|
||||||
|
# Gitea Configuration
|
||||||
|
GITEA_TOKEN=your_gitea_api_token_here
|
||||||
|
|
||||||
|
# Security (сгенерируйте случайный ключ)
|
||||||
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
# WebDAV (optional)
|
||||||
|
WEBDAV_USER=admin
|
||||||
|
WEBDAV_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# Database (обязательно измените!)
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Создание директорий
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p api/logs
|
||||||
|
mkdir -p nginx/html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### 1. Первый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Проверка статуса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Инициализация Gitea
|
||||||
|
|
||||||
|
Откройте `http://your-server-ip:3000` и выполните первоначальную настройку:
|
||||||
|
|
||||||
|
1. Установите базу данных (уже настроена PostgreSQL)
|
||||||
|
2. Создайте администратора
|
||||||
|
3. Создайте API токен в Settings → Applications
|
||||||
|
4. Добавьте токен в `.env` файл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Перезапустите сервисы
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройка домена и SSL
|
||||||
|
|
||||||
|
### 1. Настройка DNS
|
||||||
|
|
||||||
|
Укажите A-записи:
|
||||||
|
- `your-domain.com` → IP сервера
|
||||||
|
- `git.your-domain.com` → IP сервера
|
||||||
|
- `api.your-domain.com` → IP сервера
|
||||||
|
|
||||||
|
### 2. Установка Certbot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y certbot
|
||||||
|
|
||||||
|
# Получение сертификатов
|
||||||
|
certbot certonly --standalone -d your-domain.com -d git.your-domain.com -d api.your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка Nginx
|
||||||
|
|
||||||
|
Отредактируйте `nginx/nginx.conf`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Раскомментируйте HTTPS server section
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# ... rest of config
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Обновление docker-compose.yml
|
||||||
|
|
||||||
|
Добавьте volume для SSL сертификатов:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nginx:
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/html:/usr/share/nginx/html:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro # Добавьте это
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Перезапуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подключение Flutter-приложения
|
||||||
|
|
||||||
|
### 1. Настройка сервера в приложении
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:nanaxe_crm/core/nanaxe_server_service.dart';
|
||||||
|
|
||||||
|
final serverService = NanaxeServerService();
|
||||||
|
|
||||||
|
// При инициализации
|
||||||
|
await serverService.initialize();
|
||||||
|
|
||||||
|
// Если не настроен - настройте
|
||||||
|
if (!serverService.isConfigured) {
|
||||||
|
await serverService.configure(
|
||||||
|
'https://api.your-domain.com',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка соединения
|
||||||
|
final isConnected = await serverService.checkConnection();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Авторизация
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Регистрация
|
||||||
|
await serverService.register(
|
||||||
|
'user@example.com',
|
||||||
|
'password',
|
||||||
|
'username',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Или вход
|
||||||
|
await serverService.login(
|
||||||
|
'user@example.com',
|
||||||
|
'password',
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Работа с mindmaps
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Получение списка
|
||||||
|
final mindmaps = await serverService.getMindMaps();
|
||||||
|
|
||||||
|
// Создание
|
||||||
|
final newMap = await serverService.createMindMap(
|
||||||
|
title: 'My Project',
|
||||||
|
description: 'Project planning',
|
||||||
|
nodes: [
|
||||||
|
{'id': '1', 'title': 'Root', 'level': 0, 'parentId': null},
|
||||||
|
{'id': '2', 'title': 'Task 1', 'level': 1, 'parentId': '1'},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Синхронизация с Gitea
|
||||||
|
await serverService.pushToGitea(commitMessage: 'Updated project plan');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Обслуживание
|
||||||
|
|
||||||
|
### Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nanaxe-server/server
|
||||||
|
git pull
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Бэкап
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Бэкап данных
|
||||||
|
tar -czf backup-$(date +%Y%m%d).tar.gz /opt/nanaxe-server/server
|
||||||
|
|
||||||
|
# Или бэкап volumes
|
||||||
|
docker run --rm -v nanaxe-server_mindmap-data:/data -v $(pwd):/backup alpine tar czf /backup/mindmaps-backup.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Мониторинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Логи
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Статистика
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Дисковое пространство
|
||||||
|
df -h
|
||||||
|
docker system df
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Доступ к сервисам
|
||||||
|
|
||||||
|
После развёртывания:
|
||||||
|
|
||||||
|
| Сервис | URL | Описание |
|
||||||
|
|--------|-----|----------|
|
||||||
|
| Web UI | `http://your-domain.com` | Главная страница |
|
||||||
|
| Gitea | `http://your-domain.com:3000` | Git репозиторий |
|
||||||
|
| API | `http://your-domain.com:8080` | REST API |
|
||||||
|
| WebDAV | `http://your-domain.com:8081` | Прямой доступ к файлам |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Obsidian Интеграция
|
||||||
|
|
||||||
|
### 1. Git Sync плагин
|
||||||
|
|
||||||
|
Установите плагин **Obsidian Git** и настройте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В настройках плагина
|
||||||
|
Remote URL: http://your-domain.com:3000/username/mindmaps.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Прямой доступ через WebDAV
|
||||||
|
|
||||||
|
Установите плагин **Remote Save** и настройте WebDAV:
|
||||||
|
|
||||||
|
```
|
||||||
|
URL: http://your-domain.com:8081
|
||||||
|
Username: admin
|
||||||
|
Password: your_secure_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Устранение неполадок
|
||||||
|
|
||||||
|
### Проблема: Порты заняты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка занятых портов
|
||||||
|
netstat -tlnp | grep -E '3000|8080|80'
|
||||||
|
|
||||||
|
# Измените порты в docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Permission denied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте права
|
||||||
|
ls -la /opt/nanaxe-server/server
|
||||||
|
|
||||||
|
# Исправьте
|
||||||
|
chown -R $USER:$USER /opt/nanaxe-server/server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проблема: Контейнеры не запускаются
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте логи
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# Пересоберите
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Дополнительно
|
||||||
|
|
||||||
|
- [Gitea Documentation](https://docs.gitea.io/)
|
||||||
|
- [Docker Documentation](https://docs.docker.com/)
|
||||||
|
- [Nginx Documentation](https://nginx.org/en/docs/)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install git for repository operations
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy app source
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p /app/mindmaps /app/logs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "nanaxe-api-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API server for Nanaxe CRM mindmap synchronization",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"simple-git": "^3.21.0",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"yaml": "^2.3.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const mindmapRoutes = require('./routes/mindmap');
|
||||||
|
const syncRoutes = require('./routes/sync');
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const { errorHandler } = require('./middleware/errorHandler');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || '*',
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100 // limit each IP to 100 requests per windowMs
|
||||||
|
});
|
||||||
|
app.use(limiter);
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0.0'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/mindmaps', mindmapRoutes);
|
||||||
|
app.use('/api/sync', syncRoutes);
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`🚀 Nanaxe API Server running on port ${PORT}`);
|
||||||
|
console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`);
|
||||||
|
console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key';
|
||||||
|
|
||||||
|
const authenticateToken = (req, res, next) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Access token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateToken = (user) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { authenticateToken, generateToken };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
let statusCode = err.statusCode || 500;
|
||||||
|
let message = err.message || 'Internal Server Error';
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
if (err.name === 'ValidationError') {
|
||||||
|
statusCode = 400;
|
||||||
|
message = 'Validation Error: ' + message;
|
||||||
|
} else if (err.name === 'UnauthorizedError') {
|
||||||
|
statusCode = 401;
|
||||||
|
message = 'Unauthorized';
|
||||||
|
} else if (err.name === 'ForbiddenError') {
|
||||||
|
statusCode = 403;
|
||||||
|
message = 'Forbidden';
|
||||||
|
} else if (err.name === 'NotFoundError') {
|
||||||
|
statusCode = 404;
|
||||||
|
message = 'Resource not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
status: statusCode,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: req.path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { errorHandler };
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { generateToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Mock user storage (в продакшене использовать базу данных)
|
||||||
|
const users = new Map();
|
||||||
|
|
||||||
|
// Register
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, username } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password || !username) {
|
||||||
|
return res.status(400).json({ error: 'Email, password and username are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.has(email)) {
|
||||||
|
return res.status(409).json({ error: 'User already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Date.now().toString();
|
||||||
|
const user = {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password, // В продакшене хешировать!
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
users.set(email, user);
|
||||||
|
|
||||||
|
const token = generateToken(user);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'User registered successfully',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users.get(email);
|
||||||
|
|
||||||
|
if (!user || user.password !== password) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
router.get('/validate', async (req, res) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ valid: false, error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-super-secret-jwt-key');
|
||||||
|
res.json({ valid: true, user: decoded });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(403).json({ valid: false, error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const MarkdownConverter = require('../utils/markdownConverter');
|
||||||
|
|
||||||
|
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||||
|
|
||||||
|
// Ensure storage directory exists
|
||||||
|
fs.ensureDirSync(STORAGE_PATH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mindmaps - List all mindmaps
|
||||||
|
*/
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
|
||||||
|
await fs.ensureDir(userDir);
|
||||||
|
|
||||||
|
const files = await fs.readdir(userDir);
|
||||||
|
const mindmaps = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.md')) {
|
||||||
|
const filePath = path.join(userDir, file);
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const mindmap = MarkdownConverter.fromMarkdown(content);
|
||||||
|
|
||||||
|
mindmaps.push({
|
||||||
|
id: mindmap.id,
|
||||||
|
title: mindmap.title,
|
||||||
|
description: mindmap.description,
|
||||||
|
nodeCount: mindmap.nodes.length,
|
||||||
|
updatedAt: mindmap.updatedAt,
|
||||||
|
filename: file
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by updatedAt desc
|
||||||
|
mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count: mindmaps.length,
|
||||||
|
mindmaps
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing mindmaps:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list mindmaps' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mindmaps/:id - Get single mindmap
|
||||||
|
*/
|
||||||
|
router.get('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Mindmap not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||||
|
|
||||||
|
res.json(mindmap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting mindmap:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get mindmap' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mindmaps - Create new mindmap
|
||||||
|
*/
|
||||||
|
router.post('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { title, description, nodes = [], settings = {} } = req.body;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const mindmap = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description: description || '',
|
||||||
|
nodes: nodes.length > 0 ? nodes : [{
|
||||||
|
id: uuidv4(),
|
||||||
|
title: 'Root',
|
||||||
|
level: 0,
|
||||||
|
parentId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}],
|
||||||
|
settings,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
await fs.ensureDir(userDir);
|
||||||
|
|
||||||
|
const markdown = MarkdownConverter.toMarkdown(mindmap);
|
||||||
|
const filePath = path.join(userDir, `${id}.md`);
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||||
|
|
||||||
|
res.status(201).json(mindmap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating mindmap:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create mindmap' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/mindmaps/:id - Update mindmap
|
||||||
|
*/
|
||||||
|
router.put('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Mindmap not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const existing = MarkdownConverter.fromMarkdown(content, id);
|
||||||
|
|
||||||
|
// Merge updates
|
||||||
|
const updated = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id, // Preserve ID
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const markdown = MarkdownConverter.toMarkdown(updated);
|
||||||
|
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating mindmap:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update mindmap' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/mindmaps/:id - Delete mindmap
|
||||||
|
*/
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Mindmap not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.remove(filePath);
|
||||||
|
|
||||||
|
res.json({ message: 'Mindmap deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting mindmap:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete mindmap' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown
|
||||||
|
*/
|
||||||
|
router.get('/:id/markdown', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Mindmap not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/markdown');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`);
|
||||||
|
res.send(content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting markdown:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get markdown' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mindmaps/:id/markdown - Update mindmap from markdown
|
||||||
|
*/
|
||||||
|
router.post('/:id/markdown', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { markdown } = req.body;
|
||||||
|
|
||||||
|
if (!markdown) {
|
||||||
|
return res.status(400).json({ error: 'Markdown content is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
await fs.ensureDir(userDir);
|
||||||
|
|
||||||
|
const filePath = path.join(userDir, `${id}.md`);
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||||
|
|
||||||
|
// Parse and return
|
||||||
|
const mindmap = MarkdownConverter.fromMarkdown(markdown, id);
|
||||||
|
|
||||||
|
res.json(mindmap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving markdown:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to save markdown' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format
|
||||||
|
*/
|
||||||
|
router.get('/:id/markmap', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user.id;
|
||||||
|
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Mindmap not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||||
|
const markmap = MarkdownConverter.toMarkmap(mindmap);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/markdown');
|
||||||
|
res.send(markmap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting markmap:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get markmap' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const simpleGit = require('simple-git');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const MarkdownConverter = require('../utils/markdownConverter');
|
||||||
|
|
||||||
|
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||||
|
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/gitea/push - Push mindmaps to Gitea repository
|
||||||
|
*/
|
||||||
|
router.post('/gitea/push', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { repoName, commitMessage } = req.body;
|
||||||
|
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
const repoDir = path.join(userDir, '.git-repo');
|
||||||
|
|
||||||
|
if (!await fs.pathExists(userDir)) {
|
||||||
|
return res.status(404).json({ error: 'No mindmaps found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize or open git repo
|
||||||
|
const git = simpleGit(repoDir);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||||
|
await fs.ensureDir(repoDir);
|
||||||
|
await git.init();
|
||||||
|
|
||||||
|
// Configure git
|
||||||
|
await git.addConfig('user.email', req.user.email || 'user@nanaxe.app');
|
||||||
|
await git.addConfig('user.name', req.user.username || 'Nanaxe User');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all mindmaps to repo
|
||||||
|
const files = await fs.readdir(userDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.md')) {
|
||||||
|
await fs.copy(
|
||||||
|
path.join(userDir, file),
|
||||||
|
path.join(repoDir, file)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit changes
|
||||||
|
await git.add('.');
|
||||||
|
const status = await git.status();
|
||||||
|
|
||||||
|
if (status.files.length === 0) {
|
||||||
|
return res.json({ message: 'No changes to commit', synced: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM');
|
||||||
|
|
||||||
|
// Push to Gitea if configured
|
||||||
|
if (GITEA_TOKEN) {
|
||||||
|
const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if remote exists
|
||||||
|
const remotes = await git.getRemotes(true);
|
||||||
|
const originExists = remotes.some(r => r.name === 'origin');
|
||||||
|
|
||||||
|
if (!originExists) {
|
||||||
|
await git.addRemote('origin', remoteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.push('origin', 'master');
|
||||||
|
} catch (pushError) {
|
||||||
|
console.error('Push error:', pushError);
|
||||||
|
// Continue even if push fails - local commit is successful
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Mindmaps synced successfully',
|
||||||
|
synced: true,
|
||||||
|
filesCommitted: status.files.length,
|
||||||
|
commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing to Gitea:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to sync mindmaps' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository
|
||||||
|
*/
|
||||||
|
router.post('/gitea/pull', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { repoName } = req.body;
|
||||||
|
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
const repoDir = path.join(userDir, '.git-repo');
|
||||||
|
|
||||||
|
if (!GITEA_TOKEN) {
|
||||||
|
return res.status(400).json({ error: 'Gitea not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = simpleGit(repoDir);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||||
|
await fs.ensureDir(repoDir);
|
||||||
|
|
||||||
|
// Clone from Gitea
|
||||||
|
const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||||
|
await git.clone(cloneUrl, repoDir);
|
||||||
|
} else {
|
||||||
|
// Pull latest
|
||||||
|
await git.pull('origin', 'master');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy files from repo to storage
|
||||||
|
const files = await fs.readdir(repoDir);
|
||||||
|
let importedCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.md') && !file.startsWith('.')) {
|
||||||
|
await fs.copy(
|
||||||
|
path.join(repoDir, file),
|
||||||
|
path.join(userDir, file),
|
||||||
|
{ overwrite: true }
|
||||||
|
);
|
||||||
|
importedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Mindmaps pulled successfully',
|
||||||
|
imported: importedCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pulling from Gitea:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to pull mindmaps' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sync/status - Get sync status
|
||||||
|
*/
|
||||||
|
router.get('/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
const repoDir = path.join(userDir, '.git-repo');
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
hasLocalData: await fs.pathExists(userDir),
|
||||||
|
hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')),
|
||||||
|
giteaConfigured: !!GITEA_TOKEN,
|
||||||
|
lastSync: null,
|
||||||
|
pendingChanges: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.hasGitRepo) {
|
||||||
|
const git = simpleGit(repoDir);
|
||||||
|
const gitStatus = await git.status();
|
||||||
|
status.pendingChanges = gitStatus.files.length;
|
||||||
|
|
||||||
|
// Get last commit date
|
||||||
|
try {
|
||||||
|
const log = await git.log({ maxCount: 1 });
|
||||||
|
if (log.latest) {
|
||||||
|
status.lastSync = log.latest.date;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No commits yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting sync status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get sync status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/export - Export all mindmaps as zip
|
||||||
|
*/
|
||||||
|
router.post('/export', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
|
||||||
|
if (!await fs.pathExists(userDir)) {
|
||||||
|
return res.status(404).json({ error: 'No mindmaps found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary zip
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"');
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
const files = await fs.readdir(userDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.md')) {
|
||||||
|
archive.file(path.join(userDir, file), { name: file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting mindmaps:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to export mindmaps' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/import - Import mindmaps from files
|
||||||
|
*/
|
||||||
|
router.post('/import', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const { files } = req.body; // Array of { filename, content }
|
||||||
|
|
||||||
|
if (!files || !Array.isArray(files)) {
|
||||||
|
return res.status(400).json({ error: 'Files array required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDir = path.join(STORAGE_PATH, userId);
|
||||||
|
await fs.ensureDir(userDir);
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
imported: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (!file.filename.endsWith('.md')) {
|
||||||
|
results.errors.push({ file: file.filename, error: 'Not a markdown file' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate markdown
|
||||||
|
const mindmap = MarkdownConverter.fromMarkdown(file.content);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const filePath = path.join(userDir, file.filename);
|
||||||
|
await fs.writeFile(filePath, file.content, 'utf-8');
|
||||||
|
|
||||||
|
results.imported++;
|
||||||
|
} catch (error) {
|
||||||
|
results.errors.push({ file: file.filename, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing mindmaps:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to import mindmaps' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* Конвертер между MindMap форматом и Markdown (Obsidian-совместимый)
|
||||||
|
*
|
||||||
|
* Obsidian MindMap формат:
|
||||||
|
* - Заголовки (# ## ###) = уровни узлов
|
||||||
|
* - Списки (- *) = дочерние узлы
|
||||||
|
* - [[ссылки]] = внутренние связи
|
||||||
|
* - Теги #тег = метки узлов
|
||||||
|
*/
|
||||||
|
|
||||||
|
const YAML = require('yaml');
|
||||||
|
|
||||||
|
class MarkdownConverter {
|
||||||
|
/**
|
||||||
|
* Конвертирует MindMap из JSON в Markdown
|
||||||
|
* @param {Object} mindmap - Объект mindmap
|
||||||
|
* @returns {string} Markdown строка
|
||||||
|
*/
|
||||||
|
static toMarkdown(mindmap) {
|
||||||
|
const { id, title, description, nodes, settings = {} } = mindmap;
|
||||||
|
|
||||||
|
// Frontmatter с метаданными
|
||||||
|
const frontmatter = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
createdAt: mindmap.createdAt,
|
||||||
|
updatedAt: mindmap.updatedAt,
|
||||||
|
...settings
|
||||||
|
};
|
||||||
|
|
||||||
|
let markdown = '---\n';
|
||||||
|
markdown += YAML.stringify(frontmatter);
|
||||||
|
markdown += '---\n\n';
|
||||||
|
|
||||||
|
// Заголовок mindmap
|
||||||
|
markdown += `# ${title}\n\n`;
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
markdown += `> ${description}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конвертируем узлы в иерархию
|
||||||
|
if (nodes && nodes.length > 0) {
|
||||||
|
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||||
|
|
||||||
|
for (const root of rootNodes) {
|
||||||
|
markdown += this._nodeToMarkdown(nodes, root, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рекурсивно конвертирует узел в Markdown
|
||||||
|
*/
|
||||||
|
static _nodeToMarkdown(allNodes, node, level) {
|
||||||
|
const indent = ' '.repeat(level);
|
||||||
|
let markdown = '';
|
||||||
|
|
||||||
|
// Заголовок или список в зависимости от уровня
|
||||||
|
if (level === 0) {
|
||||||
|
markdown += `## ${node.title}\n`;
|
||||||
|
} else if (level === 1) {
|
||||||
|
markdown += `### ${node.title}\n`;
|
||||||
|
} else if (level === 2) {
|
||||||
|
markdown += `#### ${node.title}\n`;
|
||||||
|
} else {
|
||||||
|
markdown += `${indent}- ${node.title}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем метаданные узла
|
||||||
|
if (node.taskId) {
|
||||||
|
markdown += `${indent} - Task: [[${node.taskId}]]\n`;
|
||||||
|
}
|
||||||
|
if (node.color) {
|
||||||
|
markdown += `${indent} - Color: \`${node.color}\`\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим дочерние узлы
|
||||||
|
const children = allNodes.filter(n => n.parentId === node.id);
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
markdown += this._nodeToMarkdown(allNodes, child, level + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown += '\n';
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит Markdown в MindMap JSON
|
||||||
|
* @param {string} markdown - Markdown строка
|
||||||
|
* @returns {Object} Объект mindmap
|
||||||
|
*/
|
||||||
|
static fromMarkdown(markdown, existingId = null) {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
// Парсим frontmatter
|
||||||
|
let frontmatter = {};
|
||||||
|
let contentStart = 0;
|
||||||
|
|
||||||
|
if (lines[0] === '---') {
|
||||||
|
const endIndex = lines.indexOf('---', 1);
|
||||||
|
if (endIndex !== -1) {
|
||||||
|
const yamlContent = lines.slice(1, endIndex).join('\n');
|
||||||
|
frontmatter = YAML.parse(yamlContent) || {};
|
||||||
|
contentStart = endIndex + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим контент
|
||||||
|
const content = lines.slice(contentStart).join('\n');
|
||||||
|
const nodes = this._parseNodes(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existingId || frontmatter.id || this._generateId(),
|
||||||
|
title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap',
|
||||||
|
description: frontmatter.description || '',
|
||||||
|
nodes,
|
||||||
|
settings: frontmatter.settings || {},
|
||||||
|
createdAt: frontmatter.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит узлы из Markdown контента
|
||||||
|
*/
|
||||||
|
static _parseNodes(content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const nodes = [];
|
||||||
|
const nodeStack = []; // Стек для отслеживания родительских узлов
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Определяем уровень и тип
|
||||||
|
let level = 0;
|
||||||
|
let title = '';
|
||||||
|
let parentId = null;
|
||||||
|
|
||||||
|
// Заголовки H2-H4
|
||||||
|
const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3
|
||||||
|
title = headingMatch[2].trim();
|
||||||
|
}
|
||||||
|
// Списки
|
||||||
|
else if (trimmed.match(/^[-*]\s+/)) {
|
||||||
|
const indent = line.search(/\S/);
|
||||||
|
level = Math.floor(indent / 2) + 3;
|
||||||
|
title = trimmed.replace(/^[-*]\s+/, '');
|
||||||
|
}
|
||||||
|
// Пропускаем H1 (это заголовок карты)
|
||||||
|
else if (trimmed.startsWith('# ')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим родителя
|
||||||
|
while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
|
||||||
|
nodeStack.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeStack.length > 0) {
|
||||||
|
parentId = nodeStack[nodeStack.length - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
id: this._generateId(),
|
||||||
|
title,
|
||||||
|
level,
|
||||||
|
parentId,
|
||||||
|
color: this._extractColor(trimmed),
|
||||||
|
taskId: this._extractTaskId(trimmed),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.push(node);
|
||||||
|
nodeStack.push({ id: node.id, level });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает цвет из строки
|
||||||
|
*/
|
||||||
|
static _extractColor(line) {
|
||||||
|
const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/);
|
||||||
|
return colorMatch ? `#${colorMatch[1]}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает ID задачи из ссылки
|
||||||
|
*/
|
||||||
|
static _extractTaskId(line) {
|
||||||
|
const taskMatch = line.match(/\[\[([^\]]+)\]\]/);
|
||||||
|
return taskMatch ? taskMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает заголовок из контента
|
||||||
|
*/
|
||||||
|
static _extractTitle(content) {
|
||||||
|
const match = content.match(/^#\s+(.+)$/m);
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует уникальный ID
|
||||||
|
*/
|
||||||
|
static _generateId() {
|
||||||
|
return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует MindMap в формат Markmap (для визуализации)
|
||||||
|
* @param {Object} mindmap - Объект mindmap
|
||||||
|
* @returns {string} Markmap Markdown
|
||||||
|
*/
|
||||||
|
static toMarkmap(mindmap) {
|
||||||
|
const { title, nodes } = mindmap;
|
||||||
|
|
||||||
|
let markdown = `# ${title}\n\n`;
|
||||||
|
|
||||||
|
if (nodes && nodes.length > 0) {
|
||||||
|
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||||
|
|
||||||
|
for (const root of rootNodes) {
|
||||||
|
markdown += this._nodeToMarkmap(nodes, root, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рекурсивно конвертирует узел в Markmap формат
|
||||||
|
*/
|
||||||
|
static _nodeToMarkmap(allNodes, node, level) {
|
||||||
|
const indent = ' '.repeat(level);
|
||||||
|
let markdown = `${indent}- ${node.title}\n`;
|
||||||
|
|
||||||
|
const children = allNodes.filter(n => n.parentId === node.id);
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
markdown += this._nodeToMarkmap(allNodes, child, level + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MarkdownConverter;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Production Docker Compose Configuration
|
||||||
|
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ==========================================
|
||||||
|
# Gitea - Production Configuration
|
||||||
|
# ==========================================
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest-rootless
|
||||||
|
container_name: nanaxe-gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=postgres:5432
|
||||||
|
- GITEA__database__NAME=gitea
|
||||||
|
- GITEA__database__USER=gitea
|
||||||
|
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||||
|
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
|
||||||
|
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}
|
||||||
|
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN}
|
||||||
|
- GITEA__server__SSH_PORT=222
|
||||||
|
- GITEA__server__SSH_LISTEN_PORT=22
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__security__SECRET_KEY=${GITEA_SECRET_KEY}
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=false
|
||||||
|
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||||
|
- GITEA__log__MODE=console
|
||||||
|
- GITEA__log__LEVEL=Info
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# PostgreSQL - Production
|
||||||
|
# ==========================================
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: nanaxe-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./backups:/backups
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=gitea
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=gitea
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gitea"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.1'
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Nanaxe API - Production
|
||||||
|
# ==========================================
|
||||||
|
nanaxe-api:
|
||||||
|
image: ghcr.io/${GITHUB_REPOSITORY:-nanaxe}/nanaxe-api:latest
|
||||||
|
container_name: nanaxe-api
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- mindmap-data:/app/mindmaps
|
||||||
|
- api-logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=8080
|
||||||
|
- GITEA_URL=http://gitea:3000
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.1'
|
||||||
|
memory: 64M
|
||||||
|
replicas: 1
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
delay: 10s
|
||||||
|
failure_action: rollback
|
||||||
|
order: start-first
|
||||||
|
rollback_config:
|
||||||
|
parallelism: 1
|
||||||
|
delay: 10s
|
||||||
|
failure_action: pause
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
gitea:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Nginx - Production with SSL
|
||||||
|
# ==========================================
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nanaxe-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/html:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
- certbot-data:/etc/letsencrypt:ro
|
||||||
|
- certbot-www:/var/www/certbot:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
- nanaxe-api
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Certbot - SSL Certificates
|
||||||
|
# ==========================================
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: nanaxe-certbot
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- certbot-data:/etc/letsencrypt
|
||||||
|
- certbot-www:/var/www/certbot
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
profiles:
|
||||||
|
- ssl
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Watchtower - Auto-update containers
|
||||||
|
# ==========================================
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
container_name: nanaxe-watchtower
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- WATCHTOWER_CLEANUP=true
|
||||||
|
- WATCHTOWER_POLL_INTERVAL=3600
|
||||||
|
- WATCHTOWER_INCLUDE_STOPPED=true
|
||||||
|
- WATCHTOWER_REVIVE_STOPPED=false
|
||||||
|
- WATCHTOWER_ROLLING_RESTART=true
|
||||||
|
- WATCHTOWER_LABEL_ENABLE=true
|
||||||
|
command: --interval 3600 --cleanup
|
||||||
|
profiles:
|
||||||
|
- auto-update
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Redis - Caching (optional)
|
||||||
|
# ==========================================
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: nanaxe-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
profiles:
|
||||||
|
- cache
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nanaxe-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
driver: local
|
||||||
|
postgres-data:
|
||||||
|
driver: local
|
||||||
|
mindmap-data:
|
||||||
|
driver: local
|
||||||
|
api-logs:
|
||||||
|
driver: local
|
||||||
|
certbot-data:
|
||||||
|
driver: local
|
||||||
|
certbot-www:
|
||||||
|
driver: local
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Gitea - Self-hosted Git server для хранения markdown-файлов
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: nanaxe-gitea
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=postgres:5432
|
||||||
|
- GITEA__database__NAME=gitea
|
||||||
|
- GITEA__database__USER=gitea
|
||||||
|
- GITEA__database__PASSWD=gitea_password
|
||||||
|
- GITEA__server__DOMAIN=${GITEA_DOMAIN:-localhost}
|
||||||
|
- GITEA__server__ROOT_URL=http://${GITEA_DOMAIN:-localhost}:3000
|
||||||
|
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN:-localhost}
|
||||||
|
- GITEA__server__SSH_PORT=222
|
||||||
|
- GITEA__server__SSH_LISTEN_PORT=22
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=false
|
||||||
|
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "222:22"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
# PostgreSQL для Gitea
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: nanaxe-postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=gitea
|
||||||
|
- POSTGRES_PASSWORD=gitea_password
|
||||||
|
- POSTGRES_DB=gitea
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# Nanaxe API Server - для синхронизации mindmaps
|
||||||
|
nanaxe-api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nanaxe-api
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=8080
|
||||||
|
- GITEA_URL=http://gitea:3000
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||||
|
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- mindmap-data:/app/mindmaps
|
||||||
|
- ./api/logs:/app/logs
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
# Nginx - Reverse Proxy и статические файлы
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nanaxe-nginx
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/html:/usr/share/nginx/html:ro
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
- nanaxe-api
|
||||||
|
|
||||||
|
# (Опционально) WebDAV сервер для прямого доступа к файлам
|
||||||
|
webdav:
|
||||||
|
image: bytemark/webdav
|
||||||
|
container_name: nanaxe-webdav
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- nanaxe-network
|
||||||
|
volumes:
|
||||||
|
- mindmap-data:/var/lib/dav
|
||||||
|
environment:
|
||||||
|
- AUTH_TYPE=Basic
|
||||||
|
- USERNAME=${WEBDAV_USER:-admin}
|
||||||
|
- PASSWORD=${WEBDAV_PASSWORD:-admin}
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nanaxe-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
postgres-data:
|
||||||
|
mindmap-data:
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nanaxe Server</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card a:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #4ade80;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🚀 Nanaxe Server</h1>
|
||||||
|
<p>Self-hosted mindmap synchronization server</p>
|
||||||
|
|
||||||
|
<div class="services">
|
||||||
|
<div class="service-card">
|
||||||
|
<h3>📚 Git Repository</h3>
|
||||||
|
<p>Access your mindmaps via Git</p>
|
||||||
|
<a href="http://localhost:3000" target="_blank">Open Gitea</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="service-card">
|
||||||
|
<h3>🔌 API</h3>
|
||||||
|
<p>REST API for mindmap operations</p>
|
||||||
|
<a href="http://localhost:8080/health" target="_blank">Check API</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="service-card">
|
||||||
|
<h3>📱 WebDAV</h3>
|
||||||
|
<p>Direct file access</p>
|
||||||
|
<a href="http://localhost:8081" target="_blank">Open WebDAV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
<span>Server is running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
# Upstream servers
|
||||||
|
upstream gitea {
|
||||||
|
server gitea:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream nanaxe_api {
|
||||||
|
server nanaxe-api:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP server - redirects to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# For Let's Encrypt challenges
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uncomment for HTTPS redirect
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
|
||||||
|
# Or proxy to services for local development
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# API Server
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.*;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://nanaxe_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gitea Server
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name git.*;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://gitea;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server (uncomment when SSL certificates are ready)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# try_files $uri $uri/ /index.html;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://nanaxe_api/;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /git/ {
|
||||||
|
# proxy_pass http://gitea/;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Nanaxe Server Deployment Script
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
LOG_FILE="./logs/deploy-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Helper Functions
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Pre-deployment Checks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
log "Checking prerequisites..."
|
||||||
|
|
||||||
|
# Check Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
error "Docker is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Docker Compose
|
||||||
|
if ! docker compose version &> /dev/null; then
|
||||||
|
error "Docker Compose is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check .env file
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
error ".env file not found. Please create it from .env.example"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check compose file
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
error "Compose file $COMPOSE_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "Prerequisites check passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Backup
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
create_backup() {
|
||||||
|
log "Creating backup..."
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
if docker compose ps postgres | grep -q "running"; then
|
||||||
|
log "Backing up PostgreSQL database..."
|
||||||
|
docker compose exec -T postgres pg_dump -U gitea gitea > "$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql" 2>/dev/null || warning "Database backup failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup mindmaps
|
||||||
|
if [ -d "mindmap-data" ]; then
|
||||||
|
log "Backing up mindmaps..."
|
||||||
|
tar -czf "$BACKUP_DIR/mindmaps-$(date +%Y%m%d-%H%M%S).tar.gz" mindmap-data/ 2>/dev/null || warning "Mindmaps backup failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "Backup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Deployment
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
deploy() {
|
||||||
|
log "Starting deployment..."
|
||||||
|
|
||||||
|
# Pull latest images
|
||||||
|
log "Pulling latest images..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" pull
|
||||||
|
|
||||||
|
# Deploy with zero downtime
|
||||||
|
log "Deploying services..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||||
|
|
||||||
|
success "Deployment completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Health Checks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
health_check() {
|
||||||
|
log "Running health checks..."
|
||||||
|
|
||||||
|
local max_attempts=30
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
# Wait for services to start
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check API
|
||||||
|
log "Checking API health..."
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
||||||
|
success "API is healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $attempt -eq $max_attempts ]; then
|
||||||
|
error "API health check failed after $max_attempts attempts"
|
||||||
|
show_logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Attempt $attempt/$max_attempts: API not ready yet..."
|
||||||
|
sleep 5
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check Gitea
|
||||||
|
log "Checking Gitea health..."
|
||||||
|
attempt=1
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
if curl -sf http://localhost:3000/api/healthz > /dev/null 2>&1; then
|
||||||
|
success "Gitea is healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $attempt -eq $max_attempts ]; then
|
||||||
|
warning "Gitea health check failed (may still be initializing)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Attempt $attempt/$max_attempts: Gitea not ready yet..."
|
||||||
|
sleep 5
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
|
|
||||||
|
success "All health checks passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Cleanup
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log "Cleaning up..."
|
||||||
|
|
||||||
|
# Remove old backups (keep last 7)
|
||||||
|
if [ -d "$BACKUP_DIR" ]; then
|
||||||
|
ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | tail -n +8 | xargs -r rm
|
||||||
|
ls -t "$BACKUP_DIR"/*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean Docker
|
||||||
|
docker system prune -f
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
success "Cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Rollback
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
error "Deployment failed! Rolling back..."
|
||||||
|
|
||||||
|
# Get previous image
|
||||||
|
local previous_image=$(docker images ghcr.io/*/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1)
|
||||||
|
|
||||||
|
if [ -n "$previous_image" ]; then
|
||||||
|
log "Reverting to: $previous_image"
|
||||||
|
docker compose -f "$COMPOSE_FILE" stop api
|
||||||
|
docker compose -f "$COMPOSE_FILE" rm -f api
|
||||||
|
docker run -d --name nanaxe-api-rollback \
|
||||||
|
--network nanaxe-network \
|
||||||
|
-v mindmap-data:/app/mindmaps \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e PORT=8080 \
|
||||||
|
-p 8080:8080 \
|
||||||
|
"$previous_image"
|
||||||
|
success "Rollback completed"
|
||||||
|
else
|
||||||
|
error "No previous image found for rollback"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Show Logs
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
show_logs() {
|
||||||
|
log "Showing recent logs..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=50
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Main
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "======================================"
|
||||||
|
log "Nanaxe Server Deployment"
|
||||||
|
log "======================================"
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# Run deployment steps
|
||||||
|
check_prerequisites
|
||||||
|
create_backup
|
||||||
|
|
||||||
|
# Deploy with error handling
|
||||||
|
if deploy; then
|
||||||
|
if health_check; then
|
||||||
|
cleanup
|
||||||
|
success "======================================"
|
||||||
|
success "Deployment completed successfully!"
|
||||||
|
success "======================================"
|
||||||
|
log "API: http://localhost:8080"
|
||||||
|
log "Gitea: http://localhost:3000"
|
||||||
|
else
|
||||||
|
rollback
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rollback
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# VPS Setup Script for Nanaxe Server
|
||||||
|
# Run this on your fresh VPS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() { echo -e "${BLUE}[SETUP]${NC} $1"; }
|
||||||
|
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
|
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Configuration
|
||||||
|
# ==========================================
|
||||||
|
DEPLOY_DIR="/opt/nanaxe-server"
|
||||||
|
REPO_URL="" # Will be set interactively
|
||||||
|
DOMAIN="" # Will be set interactively
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# System Update
|
||||||
|
# ==========================================
|
||||||
|
update_system() {
|
||||||
|
log "Updating system packages..."
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
apt install -y curl wget git nano htop ufw fail2ban
|
||||||
|
success "System updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Install Docker
|
||||||
|
# ==========================================
|
||||||
|
install_docker() {
|
||||||
|
log "Installing Docker..."
|
||||||
|
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
warning "Docker already installed, skipping"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
rm get-docker.sh
|
||||||
|
|
||||||
|
usermod -aG docker $USER
|
||||||
|
|
||||||
|
success "Docker installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Setup Firewall
|
||||||
|
# ==========================================
|
||||||
|
setup_firewall() {
|
||||||
|
log "Configuring firewall..."
|
||||||
|
|
||||||
|
ufw default deny incoming
|
||||||
|
ufw default allow outgoing
|
||||||
|
|
||||||
|
ufw allow ssh
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw allow 3000/tcp # Gitea
|
||||||
|
ufw allow 8080/tcp # API
|
||||||
|
ufw allow 222/tcp # Gitea SSH
|
||||||
|
|
||||||
|
ufw --force enable
|
||||||
|
|
||||||
|
success "Firewall configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Setup Fail2Ban
|
||||||
|
# ==========================================
|
||||||
|
setup_fail2ban() {
|
||||||
|
log "Configuring Fail2Ban..."
|
||||||
|
|
||||||
|
cat > /etc/fail2ban/jail.local << 'EOF'
|
||||||
|
[DEFAULT]
|
||||||
|
bantime = 3600
|
||||||
|
findtime = 600
|
||||||
|
maxretry = 3
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
port = ssh
|
||||||
|
filter = sshd
|
||||||
|
logpath = /var/log/auth.log
|
||||||
|
maxretry = 3
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl restart fail2ban
|
||||||
|
success "Fail2Ban configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Setup Deployment Directory
|
||||||
|
# ==========================================
|
||||||
|
setup_deploy_dir() {
|
||||||
|
log "Setting up deployment directory..."
|
||||||
|
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Clone repository
|
||||||
|
if [ -n "$REPO_URL" ]; then
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p backups logs
|
||||||
|
|
||||||
|
success "Deployment directory ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Setup SSL with Let's Encrypt
|
||||||
|
# ==========================================
|
||||||
|
setup_ssl() {
|
||||||
|
log "Setting up SSL certificates..."
|
||||||
|
|
||||||
|
if [ -z "$DOMAIN" ]; then
|
||||||
|
warning "No domain specified, skipping SSL setup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt install -y certbot
|
||||||
|
|
||||||
|
# Obtain certificates
|
||||||
|
certbot certonly --standalone -d "$DOMAIN" --agree-tos --non-interactive --email admin@$DOMAIN || {
|
||||||
|
warning "SSL setup failed, continuing without SSL"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-renewal cron job
|
||||||
|
(crontab -l 2>/dev/null; echo "0 12 * * * /usr/bin/certbot renew --quiet") | crontab -
|
||||||
|
|
||||||
|
success "SSL certificates configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Create Environment File
|
||||||
|
# ==========================================
|
||||||
|
setup_env() {
|
||||||
|
log "Creating environment configuration..."
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR/server"
|
||||||
|
|
||||||
|
# Generate secrets
|
||||||
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
GITEA_SECRET_KEY=$(openssl rand -base64 32)
|
||||||
|
WEBDAV_PASSWORD=$(openssl rand -base64 16)
|
||||||
|
|
||||||
|
cat > .env << EOF
|
||||||
|
# Domain configuration
|
||||||
|
GITEA_DOMAIN=${DOMAIN:-localhost}
|
||||||
|
|
||||||
|
# Gitea Configuration
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_SECRET_KEY=$GITEA_SECRET_KEY
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=$JWT_SECRET
|
||||||
|
|
||||||
|
# WebDAV
|
||||||
|
WEBDAV_USER=admin
|
||||||
|
WEBDAV_PASSWORD=$WEBDAV_PASSWORD
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_PORT=8080
|
||||||
|
EOF
|
||||||
|
|
||||||
|
success "Environment file created"
|
||||||
|
warning "Please edit .env file and add your GITEA_TOKEN after first Gitea setup"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Create Systemd Service
|
||||||
|
# ==========================================
|
||||||
|
setup_systemd() {
|
||||||
|
log "Creating systemd service..."
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/nanaxe-server.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Nanaxe Server
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=$DEPLOY_DIR/server
|
||||||
|
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||||
|
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nanaxe-server.service
|
||||||
|
|
||||||
|
success "Systemd service created"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Interactive Setup
|
||||||
|
# ==========================================
|
||||||
|
interactive_setup() {
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo "Nanaxe Server VPS Setup"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Enter your GitHub repository URL (or leave empty to skip clone): " REPO_URL
|
||||||
|
read -p "Enter your domain name (or leave empty for local setup): " DOMAIN
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Starting setup with:"
|
||||||
|
log " Repository: ${REPO_URL:-None}"
|
||||||
|
log " Domain: ${DOMAIN:-localhost}"
|
||||||
|
echo ""
|
||||||
|
read -p "Continue? (y/n): " confirm
|
||||||
|
|
||||||
|
if [ "$confirm" != "y" ]; then
|
||||||
|
error "Setup cancelled"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Main
|
||||||
|
# ==========================================
|
||||||
|
main() {
|
||||||
|
interactive_setup
|
||||||
|
|
||||||
|
log "Starting VPS setup..."
|
||||||
|
|
||||||
|
update_system
|
||||||
|
install_docker
|
||||||
|
setup_firewall
|
||||||
|
setup_fail2ban
|
||||||
|
setup_deploy_dir
|
||||||
|
setup_ssl
|
||||||
|
setup_env
|
||||||
|
setup_systemd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
success "======================================"
|
||||||
|
success "VPS Setup Complete!"
|
||||||
|
success "======================================"
|
||||||
|
echo ""
|
||||||
|
log "Next steps:"
|
||||||
|
echo " 1. Edit $DEPLOY_DIR/server/.env with your settings"
|
||||||
|
echo " 2. Run: cd $DEPLOY_DIR/server && docker compose -f docker-compose.prod.yml up -d"
|
||||||
|
echo " 3. Open Gitea at http://${DOMAIN:-your-server-ip}:3000"
|
||||||
|
echo " 4. Create admin account and API token"
|
||||||
|
echo " 5. Add GITEA_TOKEN to .env file"
|
||||||
|
echo " 6. Restart: systemctl restart nanaxe-server"
|
||||||
|
echo ""
|
||||||
|
log "Useful commands:"
|
||||||
|
echo " - View logs: docker compose -f docker-compose.prod.yml logs -f"
|
||||||
|
echo " - Restart: systemctl restart nanaxe-server"
|
||||||
|
echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart nanaxe-server"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
// Простой тест DeepSeek API без Flutter зависимостей
|
||||||
|
void main() async {
|
||||||
|
const apiKey = 'sk-6600b6bee68745aa88b076b0c3393638';
|
||||||
|
const baseUrl = 'https://api.deepseek.com';
|
||||||
|
|
||||||
|
print('🧪 Тестирование DeepSeek API...\n');
|
||||||
|
print('=' * 60);
|
||||||
|
|
||||||
|
// Тест 1: Проверка API ключа (простой запрос)
|
||||||
|
print('\n📝 Тест 1: Проверка API ключа');
|
||||||
|
print('-' * 60);
|
||||||
|
await testApiKey(apiKey, baseUrl);
|
||||||
|
|
||||||
|
// Тест 2: Генерация подзадач
|
||||||
|
print('\n📋 Тест 2: Генерация подзадач');
|
||||||
|
print('-' * 60);
|
||||||
|
await testGenerateSubtasks(apiKey, baseUrl);
|
||||||
|
|
||||||
|
// Тест 3: Приоритизация задачи
|
||||||
|
print('\n🎯 Тест 3: Приоритизация задачи');
|
||||||
|
print('-' * 60);
|
||||||
|
await testPrioritizeTask(apiKey, baseUrl);
|
||||||
|
|
||||||
|
// Тест 4: Генерация описания
|
||||||
|
print('\n✍️ Тест 4: Генерация описания задачи');
|
||||||
|
print('-' * 60);
|
||||||
|
await testGenerateDescription(apiKey, baseUrl);
|
||||||
|
|
||||||
|
// Тест 5: Генерация mind-карты
|
||||||
|
print('️ Тест 5: Генерация mind-карты');
|
||||||
|
print('-' * 60);
|
||||||
|
await testGenerateMindMap(apiKey, baseUrl);
|
||||||
|
|
||||||
|
// Тест 6: Умное планирование
|
||||||
|
print('\n📅 Тест 6: Умное планирование');
|
||||||
|
print('-' * 60);
|
||||||
|
await testSmartSchedule(apiKey, baseUrl);
|
||||||
|
|
||||||
|
print('\n${'=' * 60}');
|
||||||
|
print('✅ Тестирование завершено!');
|
||||||
|
print('=' * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 1: Проверка API ключа
|
||||||
|
Future<void> testApiKey(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': 'Привет! Скажи "API работает отлично!"',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'max_tokens': 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final message = data['choices'][0]['message']['content'];
|
||||||
|
print('✅ API ключ валиден!');
|
||||||
|
print('💬 Ответ: $message');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 2: Генерация подзадач
|
||||||
|
Future<void> testGenerateSubtasks(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': 'You are a task planning assistant. Break down tasks into logical subtasks. Return ONLY a JSON array.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': 'Break down this task into 3-7 subtasks:\n\nTitle: Создать CRM систему\nDescription: Разработка CRM для управления задачами команды с AI планированием\n\nReturn ONLY a JSON array with format: [{"title": "...", "estimatedHours": 0, "description": "..."}]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'temperature': 0.7,
|
||||||
|
'max_tokens': 2000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final content = data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
print('✅ Подзадачи сгенерированы!');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
print('\n📝 Результат:');
|
||||||
|
|
||||||
|
// Пытаемся распарсить JSON
|
||||||
|
final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content);
|
||||||
|
if (jsonMatch != null) {
|
||||||
|
final List<dynamic> subtasks = jsonDecode(jsonMatch.group(0)!);
|
||||||
|
for (int i = 0; i < subtasks.length; i++) {
|
||||||
|
final task = subtasks[i];
|
||||||
|
print('\n${i + 1}. ${task['title']}');
|
||||||
|
print(' ⏱️ Часы: ${task['estimatedHours']}');
|
||||||
|
print(' 📄 ${task['description']}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print(content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 3: Приоритизация задачи
|
||||||
|
Future<void> testPrioritizeTask(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': 'You are a task prioritization assistant. Determine task priority: low, medium, high, or urgent. Reply with ONE word only.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': 'What priority should this task have?\n\nTitle: Срочный баг - пользователи не могут войти\nDescription: Критическая ошибка авторизации, все пользователи заблокированы\nDue: 2026-04-09T12:00:00\n\nReply with ONE word: low, medium, high, or urgent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'temperature': 0.3,
|
||||||
|
'max_tokens': 10,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final priority = data['choices'][0]['message']['content'].trim().toLowerCase();
|
||||||
|
|
||||||
|
print('✅ Приоритизация выполнена!');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
print('🎯 Приоритет: ${priority.toUpperCase()}');
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 4: Генерация описания
|
||||||
|
Future<void> testGenerateDescription(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': 'You are a task description writer. Write clear, concise task descriptions in Russian. Keep it under 100 words.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': 'Write a brief description for this task:\n\nTitle: Интеграция с Google Calendar\nContext: Синхронизация событий между CRM и Google Calendar\n\nWrite in Russian.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'temperature': 0.5,
|
||||||
|
'max_tokens': 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final description = data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
print('✅ Описание сгенерировано!');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
print('\n📝 Описание:\n$description');
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 5: Генерация mind-карты
|
||||||
|
Future<void> testGenerateMindMap(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': 'You are a project structure assistant. Create a mind-map structure for projects. Return ONLY a JSON array of nodes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': '''Create a mind-map structure for this project:
|
||||||
|
|
||||||
|
Project: CRM App
|
||||||
|
Description: Мобильное CRM приложение с AI планированием, mind-картами и интеграцией с календарем
|
||||||
|
|
||||||
|
Return JSON array of nodes with format:
|
||||||
|
[{"level": 0, "title": "Project Name", "parentId": null}, {"level": 1, "title": "Phase 1", "parentId": 0}, ...]''',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'temperature': 0.7,
|
||||||
|
'max_tokens': 3000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final content = data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
print('✅ Mind-карта сгенерирована!');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
|
||||||
|
// Пытаемся распарсить JSON
|
||||||
|
final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content);
|
||||||
|
if (jsonMatch != null) {
|
||||||
|
final List<dynamic> nodes = jsonDecode(jsonMatch.group(0)!);
|
||||||
|
print('\n🗺️ Структура mind-карты:');
|
||||||
|
for (final node in nodes) {
|
||||||
|
final indent = ' ' * (node['level'] as int);
|
||||||
|
print('$indent${node['level'] == 0 ? '📌' : '└'} ${node['title']}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('\n📝 Результат:\n$content');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 6: Умное планирование
|
||||||
|
Future<void> testSmartSchedule(String apiKey, String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final tasks = [
|
||||||
|
{'title': 'Анализ требований', 'estimatedHours': 4, 'priority': 'high'},
|
||||||
|
{'title': 'Проектирование', 'estimatedHours': 6, 'priority': 'high'},
|
||||||
|
{'title': 'Frontend разработка', 'estimatedHours': 12, 'priority': 'medium'},
|
||||||
|
{'title': 'Backend разработка', 'estimatedHours': 10, 'priority': 'medium'},
|
||||||
|
{'title': 'Тестирование', 'estimatedHours': 5, 'priority': 'high'},
|
||||||
|
];
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/v1/chat/completions'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $apiKey',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': 'deepseek-chat',
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': 'You are a smart scheduling assistant. Distribute tasks across available days optimally. Return ONLY a JSON array.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'role': 'user',
|
||||||
|
'content': '''Schedule these tasks optimally:
|
||||||
|
${jsonEncode(tasks)}
|
||||||
|
|
||||||
|
Available hours per day: 6
|
||||||
|
Start date: 2026-04-09
|
||||||
|
End date: 2026-04-16
|
||||||
|
|
||||||
|
Return JSON array: [{"taskId": index, "taskName": "name", "date": "YYYY-MM-DD", "hours": 0}]''',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'temperature': 0.5,
|
||||||
|
'max_tokens': 3000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final content = data['choices'][0]['message']['content'];
|
||||||
|
|
||||||
|
print('✅ Планирование выполнено!');
|
||||||
|
print('📊 Использовано токенов: ${data['usage']['total_tokens']}');
|
||||||
|
|
||||||
|
// Пытаемся распарсить JSON
|
||||||
|
final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content);
|
||||||
|
if (jsonMatch != null) {
|
||||||
|
final List<dynamic> schedule = jsonDecode(jsonMatch.group(0)!);
|
||||||
|
print('\n📅 Расписание:');
|
||||||
|
for (final item in schedule) {
|
||||||
|
print(' 📆 ${item['date']}: ${item['taskName'] ?? item['taskId']} (${item['hours']}ч)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('\n📝 Результат:\n$content');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('❌ Ошибка: ${response.statusCode}');
|
||||||
|
print('📄 Ответ: ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Исключение: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Тестирование DeepSeek сервиса в mock режиме
|
||||||
|
// Запуск: dart run test_deepseek_mock.dart
|
||||||
|
|
||||||
|
|
||||||
|
// Минимальные модели для теста
|
||||||
|
enum TaskPriority { low, medium, high, urgent }
|
||||||
|
|
||||||
|
class TaskModel {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final int estimatedHours;
|
||||||
|
final TaskPriority priority;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
|
||||||
|
TaskModel({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.estimatedHours,
|
||||||
|
required this.priority,
|
||||||
|
this.dueDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
print('🧪 Тестирование DeepSeek сервиса (MOCK режим)\n');
|
||||||
|
print('=' * 60);
|
||||||
|
|
||||||
|
// Импортируем сервис после определения моделей
|
||||||
|
await runTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runTests() async {
|
||||||
|
// Тест 1: Генерация подзадач
|
||||||
|
print('\n📋 Тест 1: Генерация подзадач');
|
||||||
|
print('-' * 60);
|
||||||
|
final subtasks = mockGenerateSubtasks(
|
||||||
|
title: 'Создать CRM систему',
|
||||||
|
description: 'Разработка CRM для управления задачами команды',
|
||||||
|
);
|
||||||
|
print('✅ Сгенерировано подзадач: ${subtasks.length}');
|
||||||
|
for (int i = 0; i < subtasks.length; i++) {
|
||||||
|
final task = subtasks[i];
|
||||||
|
print('\n${i + 1}. ${task['title']}');
|
||||||
|
print(' ⏱️ Часы: ${task['estimatedHours']}');
|
||||||
|
print(' 📄 ${task['description']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 2: Приоритизация
|
||||||
|
print('\n\n🎯 Тест 2: Приоритизация задачи');
|
||||||
|
print('-' * 60);
|
||||||
|
final urgentPriority = mockPrioritizeTask(
|
||||||
|
dueDate: DateTime.now().add(Duration(hours: 12)),
|
||||||
|
);
|
||||||
|
print('Срочная задача (12 часов): ${urgentPriority.name.toUpperCase()}');
|
||||||
|
|
||||||
|
final mediumPriority = mockPrioritizeTask(
|
||||||
|
dueDate: DateTime.now().add(Duration(days: 5)),
|
||||||
|
);
|
||||||
|
print('Средняя задача (5 дней): ${mediumPriority.name.toUpperCase()}');
|
||||||
|
|
||||||
|
final lowPriority = mockPrioritizeTask(
|
||||||
|
dueDate: DateTime.now().add(Duration(days: 14)),
|
||||||
|
);
|
||||||
|
print('Низкий приоритет (14 дней): ${lowPriority.name.toUpperCase()}');
|
||||||
|
|
||||||
|
// Тест 3: Генерация описания
|
||||||
|
print('\n\n✍️ Тест 3: Генерация описания');
|
||||||
|
print('-' * 60);
|
||||||
|
final description = mockGenerateDescription(
|
||||||
|
'Интеграция с Google Calendar',
|
||||||
|
'Синхронизация событий между CRM и календарем',
|
||||||
|
);
|
||||||
|
print('📝 Описание:\n$description');
|
||||||
|
|
||||||
|
// Тест 4: Умное планирование
|
||||||
|
print('\n\n📅 Тест 4: Умное планирование');
|
||||||
|
print('-' * 60);
|
||||||
|
final tasks = [
|
||||||
|
TaskModel(
|
||||||
|
id: '1',
|
||||||
|
title: 'Анализ',
|
||||||
|
estimatedHours: 4,
|
||||||
|
priority: TaskPriority.high),
|
||||||
|
TaskModel(
|
||||||
|
id: '2',
|
||||||
|
title: 'Проектирование',
|
||||||
|
estimatedHours: 6,
|
||||||
|
priority: TaskPriority.high),
|
||||||
|
TaskModel(
|
||||||
|
id: '3',
|
||||||
|
title: 'Разработка',
|
||||||
|
estimatedHours: 12,
|
||||||
|
priority: TaskPriority.medium),
|
||||||
|
];
|
||||||
|
final schedule = mockSchedule(
|
||||||
|
tasks: tasks,
|
||||||
|
startDate: DateTime.now(),
|
||||||
|
endDate: DateTime.now().add(Duration(days: 7)),
|
||||||
|
availableHoursPerDay: 6,
|
||||||
|
);
|
||||||
|
print('📊 Запланировано задач: ${schedule.length}');
|
||||||
|
for (final item in schedule) {
|
||||||
|
final task = tasks[item['taskId'] as int];
|
||||||
|
print(
|
||||||
|
' 📆 ${item['date'].toString().split(' ')[0]}: ${task.title} (${item['hours']}ч)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 5: Mind-карта
|
||||||
|
print('\n\n🗺️ Тест 5: Генерация mind-карты');
|
||||||
|
print('-' * 60);
|
||||||
|
final mindMap = mockMindMap('CRM App');
|
||||||
|
print('📊 Узлов в mind-карте: ${mindMap.length}');
|
||||||
|
for (final node in mindMap) {
|
||||||
|
final indent = ' ' * (node['level'] as int);
|
||||||
|
final icon = node['level'] == 0 ? '📌' : '└';
|
||||||
|
print('$indent$icon ${node['title']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
print('\n${'=' * 60}');
|
||||||
|
print('✅ Все тесты пройдены! Mock режим работает отлично!');
|
||||||
|
print('=' * 60);
|
||||||
|
print('\n💡 Чтобы включить реальный API:');
|
||||||
|
print(' 1. Пополните баланс на https://platform.deepseek.com/billing');
|
||||||
|
print(
|
||||||
|
' 2. В lib/core/deepseek_service.dart установите useMockMode = false');
|
||||||
|
print('=' * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock функции (копия из deepseek_service.dart)
|
||||||
|
List<Map<String, dynamic>> mockGenerateSubtasks({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'title': 'Анализ требований',
|
||||||
|
'estimatedHours': 2,
|
||||||
|
'description': 'Изучить требования и документацию'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Проектирование',
|
||||||
|
'estimatedHours': 3,
|
||||||
|
'description': 'Спроектировать архитектуру решения'
|
||||||
|
},
|
||||||
|
{'title': 'Реализация', 'estimatedHours': 8, 'description': 'Написать код'},
|
||||||
|
{
|
||||||
|
'title': 'Тестирование',
|
||||||
|
'estimatedHours': 3,
|
||||||
|
'description': 'Протестировать функциональность'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Документирование',
|
||||||
|
'estimatedHours': 2,
|
||||||
|
'description': 'Создать документацию'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskPriority mockPrioritizeTask({DateTime? dueDate}) {
|
||||||
|
if (dueDate == null) return TaskPriority.medium;
|
||||||
|
|
||||||
|
final daysUntilDue = dueDate.difference(DateTime.now()).inDays;
|
||||||
|
|
||||||
|
if (daysUntilDue <= 1) return TaskPriority.urgent;
|
||||||
|
if (daysUntilDue <= 3) return TaskPriority.high;
|
||||||
|
if (daysUntilDue <= 7) return TaskPriority.medium;
|
||||||
|
return TaskPriority.low;
|
||||||
|
}
|
||||||
|
|
||||||
|
String mockGenerateDescription(String title, String? context) {
|
||||||
|
final descriptions = {
|
||||||
|
'Анализ требований':
|
||||||
|
'Изучить и проанализировать все требования проекта. Подготовить документацию с детальным описанием функционала и ожиданий пользователей.',
|
||||||
|
'Проектирование':
|
||||||
|
'Разработать архитектуру решения, создать схемы базы данных и определить основные компоненты системы.',
|
||||||
|
'Реализация':
|
||||||
|
'Написать чистый, поддерживаемый код согласно лучшим практикам. Реализовать весь запланированный функционал.',
|
||||||
|
'Тестирование':
|
||||||
|
'Провести комплексное тестирование: модульные, интеграционные и end-to-end тесты для обеспечения качества.',
|
||||||
|
'Деплой':
|
||||||
|
'Подготовить инфраструктуру, настроить CI/CD пайплайн и развернуть приложение на production сервере.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[title] ??
|
||||||
|
'Задача: $title. ${context ?? "Выполнить в срок с надлежащим качеством."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> mockSchedule({
|
||||||
|
required List<TaskModel> tasks,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
required int availableHoursPerDay,
|
||||||
|
}) {
|
||||||
|
final schedule = <Map<String, dynamic>>[];
|
||||||
|
var currentDate = startDate;
|
||||||
|
var remainingHours = availableHoursPerDay;
|
||||||
|
|
||||||
|
for (var i = 0; i < tasks.length; i++) {
|
||||||
|
if (currentDate.isAfter(endDate)) break;
|
||||||
|
|
||||||
|
final task = tasks[i];
|
||||||
|
if (task.estimatedHours <= remainingHours) {
|
||||||
|
schedule.add({
|
||||||
|
'taskId': i,
|
||||||
|
'date': currentDate,
|
||||||
|
'hours': task.estimatedHours,
|
||||||
|
});
|
||||||
|
remainingHours -= task.estimatedHours;
|
||||||
|
} else {
|
||||||
|
currentDate = currentDate.add(const Duration(days: 1));
|
||||||
|
remainingHours = availableHoursPerDay;
|
||||||
|
|
||||||
|
if (currentDate.isAfter(endDate)) break;
|
||||||
|
|
||||||
|
schedule.add({
|
||||||
|
'taskId': i,
|
||||||
|
'date': currentDate,
|
||||||
|
'hours': task.estimatedHours > remainingHours
|
||||||
|
? remainingHours
|
||||||
|
: task.estimatedHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> mockMindMap(String projectName) {
|
||||||
|
return [
|
||||||
|
{'level': 0, 'title': projectName, 'parentId': null},
|
||||||
|
{'level': 1, 'title': 'Планирование', 'parentId': 0},
|
||||||
|
{'level': 1, 'title': 'Разработка', 'parentId': 0},
|
||||||
|
{'level': 1, 'title': 'Тестирование', 'parentId': 0},
|
||||||
|
{'level': 1, 'title': 'Деплой', 'parentId': 0},
|
||||||
|
{'level': 2, 'title': 'Анализ', 'parentId': 1},
|
||||||
|
{'level': 2, 'title': 'Дизайн', 'parentId': 1},
|
||||||
|
{'level': 2, 'title': 'Frontend', 'parentId': 2},
|
||||||
|
{'level': 2, 'title': 'Backend', 'parentId': 2},
|
||||||
|
{'level': 2, 'title': 'Unit тесты', 'parentId': 3},
|
||||||
|
{'level': 2, 'title': 'E2E тесты', 'parentId': 3},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user