Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f58ca9b885 | |||
| aa412ffae9 | |||
| 8db46f4027 | |||
| 0837b3f612 | |||
| edcd30c829 | |||
| ff8a9c4e53 | |||
| 866ac86665 | |||
| fe46f49b67 | |||
| 2b8b26df3f | |||
| a0bb47b022 | |||
| 64c3928408 | |||
| c4401d35ea | |||
| 8c8c6768bd | |||
| 190e67eb26 | |||
| f740fdd57a | |||
| cdf064b187 | |||
| 8fe2075b37 | |||
| 60d25261c8 | |||
| aeed229184 | |||
| 74b093b5ad | |||
| c858b811e9 | |||
| f7dc1b2f1c | |||
| 33773b373d | |||
| bb971e665f | |||
| 7161523d01 | |||
| 4ddcaec7c8 | |||
| 0c19af34c3 | |||
| ace515f18e | |||
| f0b5883084 | |||
| 4a912d972e | |||
| 0598e07823 | |||
| 622ca04170 | |||
| 4ed6a832d1 | |||
| c979975a47 | |||
| 158f3a738b | |||
| 7dbdbde8aa | |||
| b060153761 | |||
| 73c323685f | |||
| e8c924f63a | |||
| bc2f99408d | |||
| 89b3340253 | |||
| 1f87f0d6e9 | |||
| 7da3fc8ff4 | |||
| 28bf677a0c | |||
| 699e6bbe9f | |||
| 0c5b58e26b | |||
| f933c17a90 | |||
| e8714dd00f | |||
| 68e772d2dd | |||
| 3965b7891f | |||
| fad9cacd3e | |||
| 669d8571ae | |||
| e759e9de0a | |||
| ee6ae24916 | |||
| d3eb4b87f2 | |||
| 1627faa8df | |||
| 764391dc56 | |||
| 9dde5c21dd | |||
| 275f419a41 | |||
| 65f0e7d374 | |||
| bf910adaa3 | |||
| 42899368ed | |||
| b25efbb135 | |||
| 425d3d1430 | |||
| 472024126d | |||
| c29684206d | |||
| 8ad4388d0b | |||
| d521f9a6c2 | |||
| 2663692fb3 | |||
| d9d6caf78d | |||
| c0afe4a7a5 | |||
| e040d8cacd | |||
| f9e35cb9e7 | |||
| ade67c0221 | |||
| eb91d95ec0 | |||
| 53ae4634b9 | |||
| ec10fb0efe | |||
| e4f02ff1fe | |||
| 96e52ba047 | |||
| 61c4e83a1d | |||
| 01e88c43df | |||
| c9ff1e0a81 | |||
| 73f8390a71 | |||
| 8860103557 | |||
| 636687d917 | |||
| 73e23817d7 | |||
| 54f4a331ed | |||
| 65cf8ca01f | |||
| 4c50036172 | |||
| 448403d7ee | |||
| e75b074d0d | |||
| aec030cb61 | |||
| 91f59eec00 | |||
| 55c69f821d | |||
| 02c02dd8e7 | |||
| 3a557fa77b | |||
| b281778792 | |||
| 53bfb305c6 | |||
| 9fce574c50 | |||
| e436aca25b | |||
| 263b0c1b64 | |||
| f1e0f6b5ef | |||
| 52aea9ac87 | |||
| 9272edda19 | |||
| 15e2e2224a | |||
| 28ef21b2f4 | |||
| a2f09de04d | |||
| 8e6608f788 | |||
| f871d048cd | |||
| 1b5ab70453 | |||
| f1b887fa8d | |||
| 26ef99d194 | |||
| 789ecd9d3a | |||
| 88db5daa6b | |||
| 6e428b4ede | |||
| 2336df8e86 | |||
| c5072a8a84 | |||
| bc75dc8de1 | |||
| 26939984ad | |||
| 0ff572548e | |||
| 80458c6491 | |||
| 63ce850d34 | |||
| e4a7beae04 | |||
| 9aa4943b2f | |||
| bc2dc81916 | |||
| 78d15daa35 |
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
node_modules/.cache
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.example.coolify
|
||||
dist
|
||||
*.log
|
||||
*.canvas
|
||||
|
||||
# Exclude non-web monorepo apps from Docker build context
|
||||
apps/mobile
|
||||
apps/desktop
|
||||
packages/shared-contracts/dist
|
||||
@@ -0,0 +1,47 @@
|
||||
# API Keys Configuration
|
||||
# Copy this file to .env and fill in your actual keys
|
||||
|
||||
|
||||
|
||||
# DeepSeek API (recommended - cost effective)
|
||||
# Get from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
|
||||
|
||||
# OpenAI API (alternative)
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk_your_openai_api_key_here
|
||||
|
||||
# Anthropic API (alternative)
|
||||
# Get from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Firebase Configuration (for Nuxt runtimeConfig)
|
||||
# Get from: https://console.firebase.google.com -> Project Settings -> General
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
|
||||
# Server-side Firebase API key (same as public, used for token verification)
|
||||
NUXT_FIREBASE_API_KEY=your_firebase_api_key
|
||||
|
||||
# Google Calendar API
|
||||
# Enable in Google Cloud Console
|
||||
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
|
||||
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Production: postgresql://xflow:password@postgres:5432/xflow
|
||||
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
|
||||
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
|
||||
|
||||
# Gitea Integration
|
||||
GITEA_URL=http://git.a-crm.controlsystem.kz
|
||||
GITEA_TOKEN=your_gitea_token
|
||||
GITEA_ORG=AVTEX
|
||||
GITEA_MINDMAP_REPO=MMAVTEX
|
||||
GITEA_PROJECT_REPO=Projects
|
||||
|
||||
# JWT Secret for session tokens
|
||||
NUXT_JWT_SECRET=your_random_secret_here
|
||||
@@ -0,0 +1,38 @@
|
||||
# ========================================================
|
||||
# XFlow — Coolify Environment Variables Template
|
||||
# ========================================================
|
||||
# В Coolify UI:
|
||||
# 1. Set Compose file = docker-compose.coolify.yml
|
||||
# 2. Set Dockerfile = Dockerfile.coolify
|
||||
# 3. Скопируйте эти переменные в раздел Environment Variables проекта.
|
||||
|
||||
APP_NAME=xflow
|
||||
NODE_ENV=production
|
||||
SITE_URL=https://xflow.yourdomain.com
|
||||
TZ=Asia/Almaty
|
||||
|
||||
# База данных PostgreSQL
|
||||
POSTGRES_USER=xflow
|
||||
POSTGRES_PASSWORD=generate-secure-password
|
||||
POSTGRES_DB=xflow
|
||||
DATABASE_URL=postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow
|
||||
|
||||
# Redis (кеширование и фоновые задачи)
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Безопасность и JWT
|
||||
JWT_SECRET=generate-super-secret-jwt-key-minimum-32-chars
|
||||
|
||||
# ИИ провайдеры (OpenRouter / Qwen / DeepSeek)
|
||||
OPEN_ROUTER_API_KEY=
|
||||
NUXT_OPEN_ROUTER_API_KEY=
|
||||
NUXT_QWEN_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
# Firebase Push Notifications (Web Push PWA)
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_AUTH_DOMAIN=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_STORAGE_BUCKET=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_APP_ID=
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Deploy XFlow (AVTEX/Projects)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-alpine
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
CLEAN_TOKEN=$(printf '%s' "${GIT_TOKEN}" | tr -d '\r\n')
|
||||
if [ ! -d ".git" ]; then
|
||||
git init
|
||||
git remote add origin "http://oauth2:${CLEAN_TOKEN}@git.a-crm.controlsystem.kz/AVTEX/XFlow.git"
|
||||
fi
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm install --include=dev
|
||||
ls -la node_modules/prisma || echo "Prisma not found in node_modules"
|
||||
|
||||
- name: Build Nuxt
|
||||
run: |
|
||||
npx prisma generate
|
||||
npm run build
|
||||
mkdir -p .output/server/node_modules/@prisma
|
||||
cp -r node_modules/.prisma .output/server/node_modules/
|
||||
cp -r node_modules/@prisma/* .output/server/node_modules/@prisma/
|
||||
cp -r node_modules/pg .output/server/node_modules/
|
||||
env:
|
||||
DATABASE_URL: "postgresql://admin:dummy@dummy:5432/dummy" # Нужна заглушка для билда призмы
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
echo "Docker not found and no package manager detected"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🌉 Создаем мост к серверу через временный контейнер..."
|
||||
docker stop bridge 2>/dev/null || true
|
||||
docker rm bridge 2>/dev/null || true
|
||||
docker run -d --name bridge -v /root/projects:/server alpine sleep 300
|
||||
|
||||
echo "🧹 Очистка старых файлов на сервере..."
|
||||
docker exec bridge sh -c "rm -rf /server/xflow/.output/*"
|
||||
docker exec bridge sh -c "rm -rf /server/xflow/prisma/*"
|
||||
|
||||
echo "🚀 Копирование билда и файлов на сервер..."
|
||||
docker exec bridge mkdir -p /server/xflow/.output
|
||||
docker exec bridge mkdir -p /server/xflow/prisma
|
||||
docker exec bridge mkdir -p /server/xflow/node_modules
|
||||
|
||||
docker cp .output/. bridge:/server/xflow/.output/
|
||||
docker cp prisma/. bridge:/server/xflow/prisma/
|
||||
docker cp node_modules/. bridge:/server/xflow/node_modules/
|
||||
docker cp package.json bridge:/server/xflow/package.json
|
||||
docker cp prisma.config.ts bridge:/server/xflow/prisma.config.ts
|
||||
|
||||
docker rm -f bridge
|
||||
|
||||
echo "🔄 Перезапуск приложения через единый docker-compose.yml на хосте..."
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /root/projects:/root/projects \
|
||||
-w /root/projects \
|
||||
docker:cli \
|
||||
sh -c "docker compose stop xflow_crm 2>/dev/null || true; docker compose rm -f xflow_crm 2>/dev/null || true; docker compose up -d --force-recreate --no-deps xflow_crm"
|
||||
|
||||
echo "⏳ Waiting for container to start..."
|
||||
sleep 5
|
||||
|
||||
# Миграции с использованием новой БД xflow_crm_db
|
||||
docker exec xflow_crm npx prisma migrate deploy
|
||||
docker exec xflow_crm npx prisma db seed
|
||||
echo "✅ XFlow CRM deployed at $(date)"
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
local.properties
|
||||
/.idea/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!/.env.example
|
||||
|
||||
# Nuxt
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
dist
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
/build
|
||||
build/
|
||||
app/build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
Thumbs.db
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
server/api/node_modules/
|
||||
obsidian-git/
|
||||
|
||||
/app/generated/prisma
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": true,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "5578cc3a84253281",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "08c5d1d78034a917",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "3f38aefa8626b1f8",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "canvas",
|
||||
"state": {
|
||||
"file": "session-2026-04-23.canvas",
|
||||
"viewState": {
|
||||
"x": -30,
|
||||
"y": 220,
|
||||
"zoom": -1.3211285963682102
|
||||
}
|
||||
},
|
||||
"icon": "lucide-layout-dashboard",
|
||||
"title": "session-2026-04-23"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "51c2518351e40de9",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e549873bfb898273",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "593bb9c31fd772d7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Файловый менеджер"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "62546ac7efa0da90",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Поиск"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1d80fa437f26e9ac",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Закладки"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "852b2aecff829521",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e7d70e8889ccb870",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "5746c4634649e54f",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Обратные ссылки для xflow-project-ru"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "75023e9b8d4fc317",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Исходящие ссылки из xflow-project-ru"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c8b2ad057ffcc230",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Теги"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c551c9749ed1b9fd",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-archive",
|
||||
"title": "Все свойства"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b81e950116c8095d",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Структура xflow-project-ru"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Меню быстрого перехода": false,
|
||||
"graph:Граф": false,
|
||||
"canvas:Создать новый холст": false,
|
||||
"daily-notes:Сегодняшняя заметка": false,
|
||||
"templates:Вставить шаблон": false,
|
||||
"command-palette:Открыть палитру команд": false,
|
||||
"bases:Создать новую базу": false
|
||||
}
|
||||
},
|
||||
"active": "3f38aefa8626b1f8",
|
||||
"lastOpenFiles": [
|
||||
"docker-compose.dev.yml",
|
||||
"xflow-project-ru.canvas",
|
||||
"session-2026-04-23.canvas",
|
||||
"app/server/utils/prisma.ts",
|
||||
"prisma.config.ts",
|
||||
"prisma/schema.prisma",
|
||||
"prisma",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/QueryableBase.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/ExecutableBase.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/OkPacketParams.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/ErrorPacketParams.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/Sequence.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/QueryableBase.d.ts",
|
||||
"node_modules/mysql2/lib/auth_plugins/caching_sha2_password.md",
|
||||
"node_modules/mysql2/README.md",
|
||||
"node_modules/remeda/README.md",
|
||||
"node_modules/valibot/README.md",
|
||||
"node_modules/valibot/LICENSE.md",
|
||||
"node_modules/zeptomatch/readme.md",
|
||||
"node_modules/better-result/README.md",
|
||||
"node_modules/env-paths/readme.md",
|
||||
"node_modules/chart.js/README.md",
|
||||
"node_modules/chart.js/LICENSE.md",
|
||||
"node_modules/@standard-schema/spec/README.md",
|
||||
"node_modules/fast-check/README.md",
|
||||
"node_modules/fast-check/CHANGELOG.md",
|
||||
"node_modules/aws-ssl-profiles/README.md",
|
||||
"node_modules/lru.min/README.md",
|
||||
"node_modules/@radix-ui/react-use-layout-effect/README.md",
|
||||
"node_modules/@radix-ui/react-use-effect-event/README.md",
|
||||
"node_modules/@radix-ui/react-use-controllable-state/README.md",
|
||||
"node_modules/@radix-ui/react-toggle/README.md",
|
||||
"node_modules/@radix-ui/react-slot/README.md",
|
||||
"node_modules/@radix-ui/react-primitive/README.md",
|
||||
"node_modules/@radix-ui/react-compose-refs/README.md",
|
||||
"node_modules/@radix-ui/primitive/README.md",
|
||||
"node_modules/svgo/node_modules/commander/Readme.md",
|
||||
"node_modules/@prisma/streams-local/dist/touch/hash_vendor/NOTICE.md",
|
||||
"node_modules/proper-lockfile/node_modules/retry/equation.gif",
|
||||
"xflow-project.canvas"
|
||||
]
|
||||
}
|
||||
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "center",
|
||||
"type": "text",
|
||||
"x": 100,
|
||||
"y": 300,
|
||||
"width": 350,
|
||||
"height": 220,
|
||||
"label": "📅 Отчет за сессию 25 мая 2026",
|
||||
"text": "# 📅 Сессия: 25 мая 2026 г.\n\nСегодня мы провели масштабное обновление XFlow, интегрировав новые инструменты планирования, приоритезации, а также готовую интерактивную среду для вашего практического задания!\n\n**Ключевые блоки изменений:**\n1. 📋 Шаблон «Мои цели на год»\n2. ⚖️ Тест на истинность цели\n3. 📅 Диаграмма Ганта\n4. 📊 Матрица Эйзенхауэра\n5. 🧪 Сборка проекта Nuxt\n\n*Изучите подключенные узлы для деталей.*",
|
||||
"style": {
|
||||
"background": "#1e1b4b",
|
||||
"border": "#fbbf24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-goals",
|
||||
"type": "task",
|
||||
"x": 550,
|
||||
"y": 50,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"label": "📋 Шаблон целей на год",
|
||||
"state": "completed",
|
||||
"priority": "high",
|
||||
"text": "## 📋 Шаблон «Мои цели на год»\n\n* **Тулбар:** Добавлена кнопка `📋 +Цели на Год` для автоматического развертывания карты из 5 целей.\n* **Структура:** Каждая цель пре-генерируется с шаблонами для ввода по методикам SMART и GROW (с полем для шагов и ресурсов).\n* **Связь с Гантом:** Все цели являются задачами и отображаются в таймлайне при заполнении дат.",
|
||||
"style": {
|
||||
"background": "#064e3b",
|
||||
"border": "#10b981"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-test",
|
||||
"type": "task",
|
||||
"x": 550,
|
||||
"y": 250,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"label": "⚖️ Тест на истинность цели",
|
||||
"state": "completed",
|
||||
"priority": "high",
|
||||
"text": "## ⚖️ Тест на истинность цели\n\n* **Сайдбар:** В свойства узла добавлен интерактивный опросник с 8 вопросами (шкала 1-10) для проверки мотивации по Заданию №2.\n* **Авторасчет:** Автоматически вычисляется сумма.\n* **Запись в узел:** Результат с подробными баллами записывается прямо в Markdown-текст узла по нажатию одной кнопки.",
|
||||
"style": {
|
||||
"background": "#0f172a",
|
||||
"border": "#10b981"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-gantt",
|
||||
"type": "task",
|
||||
"x": 550,
|
||||
"y": 450,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"label": "📅 Диаграмма Ганта",
|
||||
"state": "completed",
|
||||
"priority": "high",
|
||||
"text": "## 📅 Диаграмма Ганта\n\n* **Компонент:** Создан `GanttChartDrawer.vue` — выдвижной таймлайн снизу.\n* **Узлы задач:** Отслеживает все узлы типа `task` с заполненными датами.\n* **Фокус камеры:** Клик по задаче в списке Ганта плавно центрирует и приближает камеру Vue Flow на этот узел.",
|
||||
"style": {
|
||||
"background": "#1e293b",
|
||||
"border": "#3b82f6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-eisen",
|
||||
"type": "task",
|
||||
"x": 550,
|
||||
"y": 650,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"label": "📊 Матрица Эйзенхауэра",
|
||||
"state": "completed",
|
||||
"priority": "high",
|
||||
"text": "## 📊 Матрица Эйзенхауэра\n\n* **2x2 Сетка:** Размещает четыре квадранта (Важно/Срочно, Важно/Несрочно, Неважно/Срочно, Неважно/Несрочно).\n* **ИИ коучинг:** Подключен бэкенд OpenRouter AI для автоматической сортировки списка задач пользователя по 4 квадрантам.",
|
||||
"style": {
|
||||
"background": "#451a03",
|
||||
"border": "#f59e0b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "block-build",
|
||||
"type": "task",
|
||||
"x": 950,
|
||||
"y": 350,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"label": "🧪 Сборка проекта",
|
||||
"state": "completed",
|
||||
"priority": "high",
|
||||
"text": "## 🧪 Валидация сборки\n\n* **Команда:** `npm run build`\n* **Результат:** Успешно пройдено! Ошибок нет.\n* **Готовность:** 100% готовность к завтрашнему использованию на занятии.",
|
||||
"style": {
|
||||
"background": "#312e81",
|
||||
"border": "#6366f1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "edge-goals",
|
||||
"fromNode": "center",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-goals",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-test",
|
||||
"fromNode": "center",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-test",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-gantt",
|
||||
"fromNode": "center",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-gantt",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-eisen",
|
||||
"fromNode": "center",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-eisen",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-goals-build",
|
||||
"fromNode": "block-goals",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-build",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-test-build",
|
||||
"fromNode": "block-test",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-build",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-gantt-build",
|
||||
"fromNode": "block-gantt",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-build",
|
||||
"toSide": "left"
|
||||
},
|
||||
{
|
||||
"id": "edge-eisen-build",
|
||||
"fromNode": "block-eisen",
|
||||
"fromSide": "right",
|
||||
"toNode": "block-build",
|
||||
"toSide": "left"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Системный промпт для AI-ассистента проекта XFlow
|
||||
|
||||
## Текущая архитектура
|
||||
Проект реализован на **Nuxt 3 PWA**.
|
||||
|
||||
### Стек
|
||||
- **Frontend/Backend:** Nuxt 3 (Vue 3 + Nitro SSR)
|
||||
- **Стили:** Tailwind CSS
|
||||
- **PWA:** `@vite-pwa/nuxt` (Service Worker, манифест, оффлайн-кэш)
|
||||
- **Аутентификация:** Firebase Auth (Google Sign-In), JWT на Nitro
|
||||
- **Визуализация:** `@vue-flow/core` + `@vue-flow/background` + `@vue-flow/controls` + `@vue-flow/minimap`
|
||||
- **Markdown:** `markdown-it` (рендеринг нод)
|
||||
- **Push:** Web Push API + Notification API + вибро
|
||||
- **База данных:** In-memory (заглушка на Nitro utils/db.ts), миграция на Postgres в планах
|
||||
|
||||
### Файловая структура
|
||||
```
|
||||
app/
|
||||
app.vue # Корневой компонент
|
||||
assets/css/main.css # Tailwind + кастомные стили для Vue Flow
|
||||
components/
|
||||
CanvasViewer.vue # Основной канвас (Vue Flow)
|
||||
MarkdownNode.vue # Рендеринг markdown в нодах
|
||||
AchievementOverlay.vue # Всплывающие ачивки
|
||||
composables/
|
||||
useAuth.ts # Firebase Google Sign-In + JWT decode
|
||||
useCanvas.ts # Загрузка .canvas JSON, парсинг frontmatter
|
||||
usePush.ts # Подписка/отправка Web Push
|
||||
useAchievements.ts # Управление ачивками
|
||||
layouts/
|
||||
default.vue # Навигация + AchievementOverlay
|
||||
middleware/
|
||||
auth.ts # Проверка JWT (cookie xflow_token)
|
||||
admin.ts # Проверка role === 'admin'
|
||||
pages/
|
||||
index.vue # Дашборд (статы задач и ачивок)
|
||||
login.vue # Только Google Sign-In (Firebase)
|
||||
canvas.vue # CanvasViewer страница
|
||||
admin.vue # Панель админа (пуши, скрипты)
|
||||
settings.vue # Настройки пушей и ачивок
|
||||
plugins/
|
||||
firebase.client.ts # Инициализация Firebase SDK (только клиент)
|
||||
push-notifications.client.ts # Обработка пуш-событий
|
||||
server/
|
||||
api/auth/google.post.ts # Верификация Firebase ID token + выдача JWT
|
||||
api/auth/validate.get.ts # Проверка JWT
|
||||
api/canvas/[id].get.ts # Мок .canvas JSON
|
||||
api/nodes/[id].put.ts # Обновление статуса ноды + автовыдача ачивок
|
||||
api/nodes/index.get.ts # Список нод с RBAC (фильтр по assigned_to)
|
||||
api/achievements/index.get.ts # Список ачивок пользователя
|
||||
api/push/subscribe.post.ts # Сохранение подписки
|
||||
api/push/send.post.ts # Отправка пуша (admin only)
|
||||
utils/auth.ts # Генерация/верификация JWT (jose)
|
||||
utils/db.ts # In-memory store (users, nodes, achievements, pushSubs)
|
||||
public/
|
||||
favicon.svg
|
||||
pwa-*.svg # Иконки PWA (SVG заглушки)
|
||||
```
|
||||
|
||||
### Ключевые бизнес-правила
|
||||
1. **Авторизация:** Только Firebase Google Sign-In. Email/password удален. Cookie `xflow_token`.
|
||||
2. **RBAC:** `role: 'admin'` дает доступ к `/admin`. Обычные пользователи видят только ноды с `assigned_to == свой email`.
|
||||
3. **Canvas ноды:** Double-click переключает статус `pending -> in_progress -> completed`. При `completed` сервер автоматически выдает ачивку.
|
||||
4. **Frontmatter:** Ноды могут содержать YAML frontmatter с полями `alarm`, `script_id`, `assigned_to`.
|
||||
5. **Push:** Админ отправляет пуши через `/api/push/send`. Клиентский плагин слушает `PUSH_RECEIVED` и вибрирует.
|
||||
6. **Gamification:** Ачивки выдаются автоматически при завершении нод. Отображаются через AchievementOverlay.
|
||||
|
||||
### Конфигурация (.env)
|
||||
Обязательные переменные для Firebase:
|
||||
```
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
|
||||
NUXT_FIREBASE_API_KEY= # Серверная верификация (тот же ключ)
|
||||
NUXT_JWT_SECRET= # Секрет для подписи JWT
|
||||
```
|
||||
|
||||
## Критически важное правило: документация в .md
|
||||
**При ЛЮБЫХ изменениях архитектуры, API, бизнес-логики или структуры данных — обновляй соответствующие .md файлы.**
|
||||
|
||||
### Структура документации в проекте
|
||||
- `docs/architecture/` — архитектурные решения, ADR
|
||||
- `docs/api/` — контракты API, форматы данных
|
||||
- `docs/features/` — описание функционала
|
||||
- `README.md` — общее описание, setup, структура
|
||||
|
||||
### Правила обновления .md
|
||||
1. **Если добавляешь новый composable/component/API route** — создай или обнови `.md`:
|
||||
- Назначение
|
||||
- Зависимости
|
||||
- Публичный API (методы, параметры, типы)
|
||||
- Пример использования
|
||||
|
||||
2. **Если меняешь формат данных** (.canvas JSON, frontmatter схема):
|
||||
- Обнови документацию формата
|
||||
- Укажи версию схемы
|
||||
- Опиши миграцию если нужна
|
||||
|
||||
3. **Если добавляешь фичу**:
|
||||
- Создай `docs/features/feature-name.md`
|
||||
- Опиши user flow, edge cases
|
||||
|
||||
## Инструкция для сессии
|
||||
1. Перед изменениями — прочитай соответствующий `.md` файл и код
|
||||
2. После изменений — обнови `.md` или создай новый
|
||||
3. Не ломай существующие форматы данных без явного запроса
|
||||
4. При добавлении зависимостей — проверяй совместимость с Nuxt 3 / Vue 3 / TypeScript
|
||||
5. Все Dart/Flutter файлы удалены — не возвращай их
|
||||
6. Cookie называется `xflow_token`
|
||||
7. Проект называется **XFlow**
|
||||
@@ -0,0 +1,111 @@
|
||||
# ==========================================
|
||||
# Stage 1: builder
|
||||
# ==========================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
ENV HUSKY=0
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache libc6-compat git openssl
|
||||
|
||||
ARG SITE_URL=""
|
||||
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||
ARG REDIS_URL="redis://redis:6379"
|
||||
ARG JWT_SECRET="super-secret-jwt-key"
|
||||
ARG OPEN_ROUTER_API_KEY=""
|
||||
ARG NUXT_OPEN_ROUTER_API_KEY=""
|
||||
ARG NUXT_QWEN_API_KEY=""
|
||||
ARG DEEPSEEK_API_KEY=""
|
||||
ARG FIREBASE_API_KEY=""
|
||||
ARG FIREBASE_AUTH_DOMAIN=""
|
||||
ARG FIREBASE_PROJECT_ID=""
|
||||
ARG FIREBASE_STORAGE_BUCKET=""
|
||||
ARG FIREBASE_MESSAGING_SENDER_ID=""
|
||||
ARG FIREBASE_APP_ID=""
|
||||
ARG TZ="Asia/Almaty"
|
||||
|
||||
ENV SITE_URL=${SITE_URL} \
|
||||
DATABASE_URL=${DATABASE_URL} \
|
||||
NUXT_DATABASE_URL=${DATABASE_URL} \
|
||||
REDIS_URL=${REDIS_URL} \
|
||||
NUXT_REDIS_URL=${REDIS_URL} \
|
||||
JWT_SECRET=${JWT_SECRET} \
|
||||
NUXT_JWT_SECRET=${JWT_SECRET} \
|
||||
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
||||
NUXT_OPEN_ROUTER_API_KEY=${NUXT_OPEN_ROUTER_API_KEY} \
|
||||
NUXT_QWEN_API_KEY=${NUXT_QWEN_API_KEY} \
|
||||
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} \
|
||||
FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||
NUXT_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||
FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||
NUXT_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||
FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||
NUXT_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||
FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||
NUXT_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||
FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||
NUXT_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||
FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||
NUXT_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||
TZ=${TZ}
|
||||
|
||||
# Копируем package.json и prisma схемы из apps/web
|
||||
COPY apps/web/package*.json ./
|
||||
COPY apps/web/prisma ./prisma
|
||||
RUN npm ci --ignore-scripts && npx prisma generate
|
||||
|
||||
# Копируем исходный код apps/web
|
||||
COPY apps/web ./
|
||||
RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=development npm run build
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: runner
|
||||
# ==========================================
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
ENV HUSKY=0
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache tzdata openssl
|
||||
|
||||
ARG SITE_URL=""
|
||||
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||
ARG REDIS_URL="redis://redis:6379"
|
||||
ARG JWT_SECRET="super-secret-jwt-key"
|
||||
ARG TZ="Asia/Almaty"
|
||||
|
||||
ENV SITE_URL=${SITE_URL} \
|
||||
DATABASE_URL=${DATABASE_URL} \
|
||||
NUXT_DATABASE_URL=${DATABASE_URL} \
|
||||
REDIS_URL=${REDIS_URL} \
|
||||
NUXT_REDIS_URL=${REDIS_URL} \
|
||||
JWT_SECRET=${JWT_SECRET} \
|
||||
NUXT_JWT_SECRET=${JWT_SECRET} \
|
||||
TZ=${TZ}
|
||||
|
||||
COPY --from=builder /src/.output ./.output
|
||||
COPY --from=builder /src/prisma ./prisma
|
||||
COPY --from=builder /src/package.json ./
|
||||
COPY --from=builder /src/node_modules ./node_modules
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
ENV NUXT_HOST=0.0.0.0
|
||||
ENV NUXT_PORT=3000
|
||||
ENV NITRO_HOST=0.0.0.0
|
||||
ENV NITRO_PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && exec node .output/server/index.mjs"]
|
||||
@@ -1,91 +1,79 @@
|
||||
# Avtex — Smart Sleep & Motion Engine for Home Assistant
|
||||
# 🚀 XFlow — Nuxt 3 PWA
|
||||
|
||||
<p align="center">
|
||||
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
|
||||
</p>
|
||||
Визуальный менеджер задач на базе Obsidian Canvas. Работает офлайн, поддерживает уведомления и геймификацию.
|
||||
|
||||
<p align="center">
|
||||
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
|
||||
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
|
||||
</p>
|
||||
## 📱 Возможности
|
||||
|
||||
---
|
||||
- ✅ **Obsidian Canvas** — визуальное управление задачами через граф
|
||||
- ✅ **Markdown ноды** — рендеринг содержимого с подсветкой синтаксиса
|
||||
- ✅ **PWA + Офлайн** — Service Worker, кэширование, установка на homescreen
|
||||
- ✅ **JWT Auth** — регистрация, вход, ролевая модель (RBAC)
|
||||
- ✅ **Web Push** — пуш-уведомления даже при закрытом приложении
|
||||
- ✅ **Будильники** — локальные вибро-уведомления
|
||||
- ✅ **Ачивки** — геймификация выполнения задач
|
||||
- ✅ **Админка** — управление скриптами и рассылкой пушей
|
||||
|
||||
## 🌟 Ключевые возможности
|
||||
## 🛠 Технологический стек
|
||||
|
||||
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
|
||||
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
|
||||
* **Детекция сна при ненормированном графике**:
|
||||
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
|
||||
* **Полная совместимость с Home Assistant (HAC)**:
|
||||
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
|
||||
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
|
||||
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
|
||||
* **Устойчивость к убийцам процессов OnePlus**:
|
||||
* Реализован как Foreground Service с типом `specialUse`.
|
||||
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
|
||||
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
|
||||
* Jetpack Compose & Material 3.
|
||||
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
|
||||
* Интерактивная лента аудита событий сенсоров в реальном времени.
|
||||
|
||||
---
|
||||
| Компонент | Технология |
|
||||
|-----------|------------|
|
||||
| Framework | Nuxt 3 (Vue 3) |
|
||||
| Styling | Tailwind CSS |
|
||||
| Canvas | Vue Flow |
|
||||
| Markdown | markdown-it |
|
||||
| PWA | @vite-pwa/nuxt |
|
||||
| Backend API | Nitro (встроен в Nuxt) |
|
||||
| Auth | JWT |
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Сборка APK
|
||||
|
||||
Для сборки отладочной версии:
|
||||
### 1. Установка зависимостей
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
npm install
|
||||
```
|
||||
|
||||
Для сборки оптимизированного подписанного Release APK:
|
||||
### 2. Настройка окружения
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
cp .env.example .env
|
||||
# Отредактируй .env (JWT_SECRET, Postgres и т.д.)
|
||||
```
|
||||
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
### 2. Установка через ADB
|
||||
### 3. Запуск dev-сервера
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Сборка для production
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
app/
|
||||
├── components/ # Vue компоненты (CanvasViewer, MarkdownNode, ...)
|
||||
├── composables/ # Логика (useAuth, useCanvas, usePush, ...)
|
||||
├── layouts/ # Layout'ы страниц
|
||||
├── middleware/ # Route guards (auth, admin)
|
||||
├── pages/ # Страницы (index, login, canvas, admin)
|
||||
├── plugins/ # Client plugins (push notifications)
|
||||
├── public/ # Статические ассеты (иконки PWA)
|
||||
└── server/ # Nitro API routes
|
||||
|
||||
server/ # Legacy Express API (сохранён)
|
||||
```
|
||||
|
||||
## � Доступ
|
||||
|
||||
- **Админ** — `admin@xflow.local` / `admin123`
|
||||
- **Регистрация** — доступна на `/login`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Internal use only
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
|
||||
|
||||
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
|
||||
|
||||
```yaml
|
||||
alias: "Avtex: Автоматизация сна и пробуждения"
|
||||
trigger:
|
||||
- platform: webhook
|
||||
webhook_id: avtex_sleep
|
||||
action:
|
||||
- choose:
|
||||
- conditions: "{{ trigger.json.state == 'asleep' }}"
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: all
|
||||
- service: climate.set_preset_mode
|
||||
data:
|
||||
preset_mode: sleep
|
||||
target:
|
||||
entity_id: climate.bedroom
|
||||
- conditions: "{{ trigger.json.state == 'awake' }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.morning_lights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Стек технологий
|
||||
* **Язык**: Kotlin 2.3+
|
||||
* **UI**: Jetpack Compose, Material 3, Navigation
|
||||
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
|
||||
* **Сеть**: OkHttp 4.12
|
||||
* **Хранилище настроек**: Jetpack DataStore Preferences
|
||||
**XFlow Team** 🚀
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.example.avtex
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.example.avtex.theme.AvtexTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AvtexTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 266 KiB |
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Avtex</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
|
||||
|
||||
function loadConfig() {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return {
|
||||
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
|
||||
userId: process.env.XFLOW_USER_ID || 'default-user'
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
async function addNote(title, listTitle = 'Фильмы на просмотр', category = 'movies') {
|
||||
const config = loadConfig();
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
|
||||
|
||||
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
listTitle,
|
||||
category,
|
||||
ownerId: config.userId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
|
||||
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
|
||||
} else {
|
||||
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
|
||||
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listNotes(category) {
|
||||
const config = loadConfig();
|
||||
const query = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
|
||||
for (const list of data.lists) {
|
||||
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
|
||||
for (const item of list.items || []) {
|
||||
const check = item.isCompleted ? '☑' : '☐';
|
||||
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.error(`Ошибка:`, data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Ошибка связи с сервером: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(`
|
||||
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
|
||||
|
||||
Использование:
|
||||
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
|
||||
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
|
||||
xflow list [категория] Показать списки и элементы
|
||||
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
|
||||
|
||||
Примеры:
|
||||
xflow movie "Интерстеллар"
|
||||
xflow add "Купить молоко" --list "Покупки"
|
||||
xflow list
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'movie') {
|
||||
const title = args.slice(1).join(' ');
|
||||
if (!title) {
|
||||
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, 'Фильмы на просмотр', 'movies');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'add') {
|
||||
const title = args[1];
|
||||
let listTitle = 'Общий список';
|
||||
let category = 'general';
|
||||
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--list' && args[i + 1]) {
|
||||
listTitle = args[i + 1];
|
||||
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
|
||||
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error('Укажите текст заметки: xflow add "Заметка"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, listTitle, category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
const category = args[1];
|
||||
await listNotes(category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'config') {
|
||||
const config = loadConfig();
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
|
||||
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
|
||||
}
|
||||
saveConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Неизвестная команда: ${command}. Используйте --help`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@xflow/desktop-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "XFlow Windows/Linux Native CLI and Agent Bridge",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"xflow": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
|
||||
|
||||
interface XFlowConfig {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
function loadConfig(): XFlowConfig {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return {
|
||||
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
|
||||
userId: process.env.XFLOW_USER_ID || 'default-user'
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfig(config: XFlowConfig) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
async function addNote(title: string, listTitle: string = 'Фильмы', category: string = 'movies') {
|
||||
const config = loadConfig();
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
|
||||
|
||||
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
listTitle,
|
||||
category,
|
||||
ownerId: config.userId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json() as any;
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
|
||||
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
|
||||
} else {
|
||||
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
|
||||
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listNotes(category?: string) {
|
||||
const config = loadConfig();
|
||||
const query = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as any;
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
|
||||
for (const list of data.lists) {
|
||||
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
|
||||
for (const item of list.items || []) {
|
||||
const check = item.isCompleted ? '☑' : '☐';
|
||||
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.error(`Ошибка:`, data.message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Ошибка связи с сервером: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(`
|
||||
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
|
||||
|
||||
Использование:
|
||||
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
|
||||
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
|
||||
xflow list [категория] Показать списки и элементы
|
||||
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
|
||||
|
||||
Примеры:
|
||||
xflow movie "Интерстеллар"
|
||||
xflow add "Купить молоко" --list "Покупки"
|
||||
xflow list
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'movie') {
|
||||
const title = args.slice(1).join(' ');
|
||||
if (!title) {
|
||||
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, 'Фильмы на просмотр', 'movies');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'add') {
|
||||
const title = args[1];
|
||||
let listTitle = 'Общий список';
|
||||
let category = 'general';
|
||||
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--list' && args[i + 1]) {
|
||||
listTitle = args[i + 1];
|
||||
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
|
||||
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error('Укажите текст заметки: xflow add "Заметка"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, listTitle, category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
const category = args[1];
|
||||
await listNotes(category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'config') {
|
||||
const config = loadConfig();
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
|
||||
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
|
||||
}
|
||||
saveConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Неизвестная команда: ${command}. Используйте --help`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,13 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
local.properties
|
||||
/.idea/
|
||||
.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
build/
|
||||
app/build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
@@ -0,0 +1,91 @@
|
||||
# Avtex — Smart Sleep & Motion Engine for Home Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
|
||||
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Ключевые возможности
|
||||
|
||||
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
|
||||
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
|
||||
* **Детекция сна при ненормированном графике**:
|
||||
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
|
||||
* **Полная совместимость с Home Assistant (HAC)**:
|
||||
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
|
||||
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
|
||||
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
|
||||
* **Устойчивость к убийцам процессов OnePlus**:
|
||||
* Реализован как Foreground Service с типом `specialUse`.
|
||||
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
|
||||
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
|
||||
* Jetpack Compose & Material 3.
|
||||
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
|
||||
* Интерактивная лента аудита событий сенсоров в реальном времени.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Сборка APK
|
||||
|
||||
Для сборки отладочной версии:
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Для сборки оптимизированного подписанного Release APK:
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
### 2. Установка через ADB
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
|
||||
|
||||
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
|
||||
|
||||
```yaml
|
||||
alias: "Avtex: Автоматизация сна и пробуждения"
|
||||
trigger:
|
||||
- platform: webhook
|
||||
webhook_id: avtex_sleep
|
||||
action:
|
||||
- choose:
|
||||
- conditions: "{{ trigger.json.state == 'asleep' }}"
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: all
|
||||
- service: climate.set_preset_mode
|
||||
data:
|
||||
preset_mode: sleep
|
||||
target:
|
||||
entity_id: climate.bedroom
|
||||
- conditions: "{{ trigger.json.state == 'awake' }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.morning_lights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Стек технологий
|
||||
* **Язык**: Kotlin 2.3+
|
||||
* **UI**: Jetpack Compose, Material 3, Navigation
|
||||
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
|
||||
* **Сеть**: OkHttp 4.12
|
||||
* **Хранилище настроек**: Jetpack DataStore Preferences
|
||||
@@ -29,8 +29,22 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="xflow"
|
||||
android:host="lists" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
<service
|
||||
android:name=".engine.SleepTrackingService"
|
||||
android:enabled="true"
|
||||
@@ -6,16 +6,26 @@ import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.example.avtex.data.SettingsRepository
|
||||
import com.example.avtex.notes.NotesRepository
|
||||
import com.example.avtex.notes.XFlowSyncClient
|
||||
|
||||
class AvtexApplication : Application() {
|
||||
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
private set
|
||||
|
||||
lateinit var notesRepository: NotesRepository
|
||||
private set
|
||||
|
||||
lateinit var xflowSyncClient: XFlowSyncClient
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
settingsRepository = SettingsRepository(this)
|
||||
notesRepository = NotesRepository(this)
|
||||
xflowSyncClient = XFlowSyncClient(this)
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
@@ -23,10 +33,10 @@ class AvtexApplication : Application() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID_TRACKING,
|
||||
"Мониторинг сна Avtex",
|
||||
"Мониторинг XFlow",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Отображает текущий статус отслеживания сна и активности"
|
||||
description = "Отображает текущий статус мониторинга и синхронизации"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -35,7 +45,7 @@ class AvtexApplication : Application() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID_TRACKING = "avtex_sleep_tracking_channel"
|
||||
const val CHANNEL_ID_TRACKING = "xflow_tracking_channel"
|
||||
lateinit var instance: AvtexApplication
|
||||
private set
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.avtex
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.avtex.theme.AvtexTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIncomingIntent(intent)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AvtexTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// 1. Обработка App Actions через Deep Link xflow://lists/add
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == "xflow") {
|
||||
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
|
||||
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
|
||||
|
||||
if (!itemName.isNullOrBlank()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
|
||||
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
|
||||
|
||||
if (!itemName.isNullOrBlank()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveItemFromAssistant(itemName: String, listName: String) {
|
||||
lifecycleScope.launch {
|
||||
val repo = AvtexApplication.instance.notesRepository
|
||||
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
|
||||
|
||||
repo.addNoteItem(
|
||||
title = itemName,
|
||||
listTitle = listName,
|
||||
category = category
|
||||
)
|
||||
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"✅ Записано в XFlow: $itemName ($listName)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -28,8 +29,8 @@ import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.ui.DashboardScreen
|
||||
import com.example.avtex.ui.ListsScreen
|
||||
import com.example.avtex.ui.OnePlusGuideScreen
|
||||
import com.example.avtex.ui.SettingsScreen
|
||||
|
||||
@@ -57,10 +58,10 @@ fun MainNavigation() {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Статус") },
|
||||
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
|
||||
label = {
|
||||
Text(
|
||||
"Статус",
|
||||
"Сон",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
@@ -70,10 +71,10 @@ fun MainNavigation() {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = "Списки") },
|
||||
label = {
|
||||
Text(
|
||||
"Настройки",
|
||||
"Списки",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
@@ -83,12 +84,25 @@ fun MainNavigation() {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
|
||||
label = {
|
||||
Text(
|
||||
"Настройки",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 3,
|
||||
onClick = { selectedTab = 3 },
|
||||
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
|
||||
label = {
|
||||
Text(
|
||||
"OnePlus",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
|
||||
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
@@ -98,8 +112,9 @@ fun MainNavigation() {
|
||||
) { innerPadding ->
|
||||
when (selectedTab) {
|
||||
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
|
||||
1 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
|
||||
2 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
|
||||
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
|
||||
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
|
||||
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,14 @@ class SleepReceiver : BroadcastReceiver() {
|
||||
val motion = event.motion
|
||||
val light = event.light
|
||||
Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light")
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "SleepAPI",
|
||||
message = "Классификация: уверенность $confidence%, движение $motion/6, свет $light/6"
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastClassifyLogTimestamp >= MIN_CLASSIFY_LOG_INTERVAL_MS) {
|
||||
lastClassifyLogTimestamp = now
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "SleepAPI",
|
||||
message = "Классификация (раз в час): уверенность $confidence%, движение $motion/6, свет $light/6"
|
||||
)
|
||||
}
|
||||
SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light)
|
||||
}
|
||||
}
|
||||
@@ -43,5 +47,8 @@ class SleepReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SleepReceiver"
|
||||
const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT"
|
||||
private const val MIN_CLASSIFY_LOG_INTERVAL_MS = 60 * 60 * 1000L // Минимум 1 раз в час
|
||||
@Volatile
|
||||
private var lastClassifyLogTimestamp: Long = 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
data class NoteItem(
|
||||
val id: String,
|
||||
val listId: String,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val isCompleted: Boolean = false,
|
||||
val status: String = "todo", // "want_to_watch", "watched", "todo", "completed"
|
||||
val priority: String = "normal",
|
||||
val metadata: String = "{}",
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class NoteList(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String = "general", // "movies", "shopping", "tasks", "general"
|
||||
val description: String = "",
|
||||
val color: String? = null,
|
||||
val icon: String? = null,
|
||||
val items: List<NoteItem> = emptyList(),
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
class NotesRepository(context: Context) {
|
||||
|
||||
private val dbHelper = XFlowDatabaseHelper(context)
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
|
||||
val listsFlow: StateFlow<List<NoteList>> = _listsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
reload()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
val lists = loadListsFromDb()
|
||||
_listsFlow.value = lists
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadListsFromDb(): List<NoteList> = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.readableDatabase
|
||||
val lists = mutableListOf<NoteList>()
|
||||
|
||||
val cursorLists = db.query(
|
||||
XFlowDatabaseHelper.TABLE_LISTS,
|
||||
null, null, null, null, null, "created_at DESC"
|
||||
)
|
||||
|
||||
cursorLists.use { cLists ->
|
||||
while (cLists.moveToNext()) {
|
||||
val listId = cLists.getString(cLists.getColumnIndexOrThrow("id"))
|
||||
val title = cLists.getString(cLists.getColumnIndexOrThrow("title"))
|
||||
val category = cLists.getString(cLists.getColumnIndexOrThrow("category"))
|
||||
val description = cLists.getString(cLists.getColumnIndexOrThrow("description")) ?: ""
|
||||
val color = cLists.getString(cLists.getColumnIndexOrThrow("color"))
|
||||
val icon = cLists.getString(cLists.getColumnIndexOrThrow("icon"))
|
||||
val createdAt = cLists.getLong(cLists.getColumnIndexOrThrow("created_at"))
|
||||
|
||||
// Загружаем элементы списка
|
||||
val items = mutableListOf<NoteItem>()
|
||||
val cursorItems = db.query(
|
||||
XFlowDatabaseHelper.TABLE_ITEMS,
|
||||
null, "list_id = ?", arrayOf(listId), null, null, "created_at DESC"
|
||||
)
|
||||
|
||||
cursorItems.use { cItems ->
|
||||
while (cItems.moveToNext()) {
|
||||
val itemId = cItems.getString(cItems.getColumnIndexOrThrow("id"))
|
||||
val itemTitle = cItems.getString(cItems.getColumnIndexOrThrow("title"))
|
||||
val itemDesc = cItems.getString(cItems.getColumnIndexOrThrow("description")) ?: ""
|
||||
val isCompleted = cItems.getInt(cItems.getColumnIndexOrThrow("is_completed")) == 1
|
||||
val status = cItems.getString(cItems.getColumnIndexOrThrow("status"))
|
||||
val priority = cItems.getString(cItems.getColumnIndexOrThrow("priority"))
|
||||
val metadata = cItems.getString(cItems.getColumnIndexOrThrow("metadata")) ?: "{}"
|
||||
val itemCreatedAt = cItems.getLong(cItems.getColumnIndexOrThrow("created_at"))
|
||||
|
||||
items.add(
|
||||
NoteItem(
|
||||
id = itemId,
|
||||
listId = listId,
|
||||
title = itemTitle,
|
||||
description = itemDesc,
|
||||
isCompleted = isCompleted,
|
||||
status = status,
|
||||
priority = priority,
|
||||
metadata = metadata,
|
||||
createdAt = itemCreatedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lists.add(
|
||||
NoteList(
|
||||
id = listId,
|
||||
title = title,
|
||||
category = category,
|
||||
description = description,
|
||||
color = color,
|
||||
icon = icon,
|
||||
items = items,
|
||||
createdAt = createdAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lists
|
||||
}
|
||||
|
||||
suspend fun addNoteItem(
|
||||
title: String,
|
||||
listTitle: String = "Фильмы на просмотр",
|
||||
category: String = "movies",
|
||||
description: String = ""
|
||||
): NoteItem = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
// Ищем список по названию
|
||||
var targetListId: String? = null
|
||||
val c = db.query(
|
||||
XFlowDatabaseHelper.TABLE_LISTS,
|
||||
arrayOf("id"), "LOWER(title) = LOWER(?)", arrayOf(listTitle.trim()),
|
||||
null, null, null
|
||||
)
|
||||
c.use {
|
||||
if (it.moveToFirst()) {
|
||||
targetListId = it.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Если список не найден, создаем его
|
||||
if (targetListId == null) {
|
||||
val newListId = "list-" + UUID.randomUUID().toString()
|
||||
val cvList = ContentValues().apply {
|
||||
put("id", newListId)
|
||||
put("title", listTitle.trim())
|
||||
put("category", category)
|
||||
put("description", "Список $listTitle")
|
||||
put("created_at", System.currentTimeMillis())
|
||||
}
|
||||
db.insert(XFlowDatabaseHelper.TABLE_LISTS, null, cvList)
|
||||
targetListId = newListId
|
||||
}
|
||||
|
||||
val newItemId = "item-" + UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val cvItem = ContentValues().apply {
|
||||
put("id", newItemId)
|
||||
put("list_id", targetListId)
|
||||
put("title", title.trim())
|
||||
put("description", description)
|
||||
put("is_completed", 0)
|
||||
put("status", if (category == "movies") "want_to_watch" else "todo")
|
||||
put("priority", "normal")
|
||||
put("metadata", "{}")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(XFlowDatabaseHelper.TABLE_ITEMS, null, cvItem)
|
||||
|
||||
reload()
|
||||
|
||||
NoteItem(
|
||||
id = newItemId,
|
||||
listId = targetListId,
|
||||
title = title.trim(),
|
||||
description = description,
|
||||
isCompleted = false,
|
||||
status = if (category == "movies") "want_to_watch" else "todo",
|
||||
createdAt = now
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun toggleItem(item: NoteItem) = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
val newCompleted = !item.isCompleted
|
||||
val newStatus = if (newCompleted) {
|
||||
if (item.status == "want_to_watch") "watched" else "completed"
|
||||
} else {
|
||||
if (item.status == "watched") "want_to_watch" else "todo"
|
||||
}
|
||||
|
||||
val cv = ContentValues().apply {
|
||||
put("is_completed", if (newCompleted) 1 else 0)
|
||||
put("status", newStatus)
|
||||
}
|
||||
db.update(XFlowDatabaseHelper.TABLE_ITEMS, cv, "id = ?", arrayOf(item.id))
|
||||
reload()
|
||||
}
|
||||
|
||||
suspend fun deleteItem(itemId: String) = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
db.delete(XFlowDatabaseHelper.TABLE_ITEMS, "id = ?", arrayOf(itemId))
|
||||
reload()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
class XFlowDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE $TABLE_LISTS (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE $TABLE_ITEMS (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(list_id) REFERENCES $TABLE_LISTS(id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
// Создаем дефолтные списки для удобства пользователя
|
||||
val now = System.currentTimeMillis()
|
||||
val cvMovies = ContentValues().apply {
|
||||
put("id", "list-movies-default")
|
||||
put("title", "Фильмы на просмотр")
|
||||
put("category", "movies")
|
||||
put("description", "Список фильмов и сериалов")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(TABLE_LISTS, null, cvMovies)
|
||||
|
||||
val cvShopping = ContentValues().apply {
|
||||
put("id", "list-shopping-default")
|
||||
put("title", "Покупки")
|
||||
put("category", "shopping")
|
||||
put("description", "Список покупок")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(TABLE_LISTS, null, cvShopping)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_ITEMS")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_LISTS")
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "xflow_notes.db"
|
||||
private const val DATABASE_VERSION = 1
|
||||
|
||||
const val TABLE_LISTS = "lists"
|
||||
const val TABLE_ITEMS = "items"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class XFlowSyncClient(private val context: Context) {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun syncItemToServer(
|
||||
serverUrl: String,
|
||||
title: String,
|
||||
listTitle: String,
|
||||
category: String,
|
||||
ownerId: String = "default-user"
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (serverUrl.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("URL сервера XFlow не указан"))
|
||||
}
|
||||
|
||||
try {
|
||||
val endpoint = "${serverUrl.trimEnd('/')}/api/v1/lists/items"
|
||||
val json = JSONObject().apply {
|
||||
put("title", title)
|
||||
put("listTitle", listTitle)
|
||||
put("category", category)
|
||||
put("ownerId", ownerId)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.post(json.toString().toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
Result.success("Синхронизировано с XFlow")
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${it.code}: ${it.message}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync to XFlow server failed", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "XFlowSyncClient"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.notes.NoteItem
|
||||
import com.example.avtex.notes.NoteList
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.ElectricIndigo
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurface
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.ObsidianSurfaceHigh
|
||||
import com.example.avtex.theme.SolarAmber
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.theme.TextSecondary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
val repository = remember { AvtexApplication.instance.notesRepository }
|
||||
val lists by repository.listsFlow.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var selectedCategory by remember { mutableStateOf("all") }
|
||||
var newItemText by remember { mutableStateOf("") }
|
||||
var isAddingActive by remember { mutableStateOf(false) }
|
||||
|
||||
val categories = listOf(
|
||||
"all" to "Все",
|
||||
"movies" to "🎬 Фильмы",
|
||||
"shopping" to "🛒 Покупки",
|
||||
"tasks" to "⚡ Дела"
|
||||
)
|
||||
|
||||
val filteredLists = remember(lists, selectedCategory) {
|
||||
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "XFlow Заметки & Списки",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { repository.reload() },
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Обновить",
|
||||
tint = CyanAccent,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category Filter Chips
|
||||
item {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(categories) { (key, label) ->
|
||||
val isSelected = selectedCategory == key
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isSelected) NeonViolet else GlassBorder,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable { selectedCategory = key }
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) TextPrimary else TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Add Item Bar
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newItemText,
|
||||
onValueChange = { newItemText = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NeonViolet,
|
||||
unfocusedBorderColor = GlassBorder,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
cursorColor = NeonViolet
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (newItemText.isNotBlank()) {
|
||||
coroutineScope.launch {
|
||||
val targetCategory = if (selectedCategory == "all") "movies" else selectedCategory
|
||||
val listTitle = when (targetCategory) {
|
||||
"movies" -> "Фильмы на просмотр"
|
||||
"shopping" -> "Покупки"
|
||||
else -> "Общий список"
|
||||
}
|
||||
repository.addNoteItem(
|
||||
title = newItemText.trim(),
|
||||
listTitle = listTitle,
|
||||
category = targetCategory
|
||||
)
|
||||
newItemText = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Добавить", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lists and Items
|
||||
if (filteredLists.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Нет записей. Добавьте первую заметку или скажите Gemini!",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredLists, key = { it.id }) { list ->
|
||||
ListCard(
|
||||
list = list,
|
||||
onToggleItem = { item ->
|
||||
coroutineScope.launch { repository.toggleItem(item) }
|
||||
},
|
||||
onDeleteItem = { item ->
|
||||
coroutineScope.launch { repository.deleteItem(item.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListCard(
|
||||
list: NoteList,
|
||||
onToggleItem: (NoteItem) -> Unit,
|
||||
onDeleteItem: (NoteItem) -> Unit
|
||||
) {
|
||||
val accentColor = when (list.category) {
|
||||
"movies" -> CyanAccent
|
||||
"shopping" -> EmeraldMint
|
||||
"tasks" -> SolarAmber
|
||||
else -> NeonViolet
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// List Title Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor)
|
||||
)
|
||||
Text(
|
||||
text = list.title,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${list.items.count { !it.isCompleted }} активных",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
||||
// Items list
|
||||
if (list.items.isEmpty()) {
|
||||
Text(
|
||||
text = "Список пуст",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
} else {
|
||||
list.items.forEach { item ->
|
||||
ItemRow(
|
||||
item = item,
|
||||
accentColor = accentColor,
|
||||
onToggle = { onToggleItem(item) },
|
||||
onDelete = { onDeleteItem(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemRow(
|
||||
item: NoteItem,
|
||||
accentColor: Color,
|
||||
onToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.clickable { onToggle() }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = item.isCompleted,
|
||||
onCheckedChange = { onToggle() },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = accentColor,
|
||||
uncheckedColor = TextMuted,
|
||||
checkmarkColor = ObsidianBg
|
||||
),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
fontSize = 14.sp,
|
||||
color = if (item.isCompleted) TextMuted else TextPrimary,
|
||||
textDecoration = if (item.isCompleted) TextDecoration.LineThrough else TextDecoration.None
|
||||
)
|
||||
if (item.status == "want_to_watch") {
|
||||
Text(
|
||||
text = "🎬 Буду смотреть",
|
||||
fontSize = 10.sp,
|
||||
color = CyanAccent
|
||||
)
|
||||
} else if (item.status == "watched") {
|
||||
Text(
|
||||
text = "✔ Просмотрено",
|
||||
fontSize = 10.sp,
|
||||
color = EmeraldMint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Удалить",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 688 B |
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 570 B After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">XFlow</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Добавление элемента в список через Gemini / Google Assistant -->
|
||||
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/add">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
<parameter
|
||||
android:name="itemListElement.name"
|
||||
android:key="item_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
<!-- Создание списка через Gemini / Google Assistant -->
|
||||
<capability android:name="actions.intent.CREATE_ITEM_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/create">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
<!-- Просмотр списка -->
|
||||
<capability android:name="actions.intent.GET_ITEM_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/view">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,47 @@
|
||||
# API Keys Configuration
|
||||
# Copy this file to .env and fill in your actual keys
|
||||
|
||||
|
||||
|
||||
# DeepSeek API (recommended - cost effective)
|
||||
# Get from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
|
||||
|
||||
# OpenAI API (alternative)
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk_your_openai_api_key_here
|
||||
|
||||
# Anthropic API (alternative)
|
||||
# Get from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Firebase Configuration (for Nuxt runtimeConfig)
|
||||
# Get from: https://console.firebase.google.com -> Project Settings -> General
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
|
||||
# Server-side Firebase API key (same as public, used for token verification)
|
||||
NUXT_FIREBASE_API_KEY=your_firebase_api_key
|
||||
|
||||
# Google Calendar API
|
||||
# Enable in Google Cloud Console
|
||||
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
|
||||
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Production: postgresql://xflow:password@postgres:5432/xflow
|
||||
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
|
||||
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
|
||||
|
||||
# Gitea Integration
|
||||
GITEA_URL=http://git.a-crm.controlsystem.kz
|
||||
GITEA_TOKEN=your_gitea_token
|
||||
GITEA_ORG=AVTEX
|
||||
GITEA_MINDMAP_REPO=MMAVTEX
|
||||
GITEA_PROJECT_REPO=Projects
|
||||
|
||||
# JWT Secret for session tokens
|
||||
NUXT_JWT_SECRET=your_random_secret_here
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { initAuth } = useAuth()
|
||||
initAuth()
|
||||
|
||||
useHead({
|
||||
title: 'XFlow',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
|
||||
{ name: 'theme-color', content: '#0f172a' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.vue-flow {
|
||||
@apply bg-slate-900;
|
||||
}
|
||||
|
||||
.vue-flow__node {
|
||||
@apply bg-slate-800 border border-slate-700 rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
.vue-flow__node.selected {
|
||||
@apply border-blue-500 ring-2 ring-blue-500/50;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__controls {
|
||||
@apply bg-slate-800 border-slate-700;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
@apply bg-slate-700 text-slate-200 hover:bg-slate-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
name="achievement"
|
||||
tag="div"
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="ach in visibleAchievements"
|
||||
:key="ach.id"
|
||||
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
|
||||
>
|
||||
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismiss(ach.id)"
|
||||
class="text-slate-500 hover:text-slate-300 ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
achievements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const visibleAchievements = ref([])
|
||||
|
||||
watch(() => props.achievements, (newAchievements) => {
|
||||
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
|
||||
incoming.forEach(ach => {
|
||||
visibleAchievements.value.push(ach)
|
||||
setTimeout(() => dismiss(ach.id), 5000)
|
||||
})
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const dismiss = (id) => {
|
||||
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-enter-active,
|
||||
.achievement-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.achievement-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
.achievement-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/80 backdrop-blur-xl border-t border-slate-800 pb-safe z-40">
|
||||
<div class="flex justify-around items-center h-16">
|
||||
<NuxtLink to="/" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🏠</span>
|
||||
<span class="text-[10px] font-medium">Главная</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/canvases" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🗺️</span>
|
||||
<span class="text-[10px] font-medium">Канвасы</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/tasks" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">✅</span>
|
||||
<span class="text-[10px] font-medium">Задачи</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-red-500">
|
||||
<span class="text-xl">🛡️</span>
|
||||
<span class="text-[10px] font-medium">Админ</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/settings" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">⚙️</span>
|
||||
<span class="text-[10px] font-medium">Опции</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/profile" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">👤</span>
|
||||
<span class="text-[10px] font-medium">Профиль</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const auth = useAuth()
|
||||
const isAdmin = computed(() => auth.user.value?.role === 'admin')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="h-screen w-full relative">
|
||||
<VueFlow
|
||||
v-model="elements"
|
||||
:default-zoom="0.8"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
@node-double-click="onNodeDoubleClick"
|
||||
>
|
||||
<Background pattern-color="#334155" :gap="20" />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
|
||||
|
||||
<template #node-text="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #node-file="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
:is-file="true"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
</VueFlow>
|
||||
|
||||
<div class="absolute top-4 left-4 z-10 bg-slate-800/90 backdrop-blur border border-slate-700 rounded-lg p-3">
|
||||
<h2 class="text-sm font-semibold text-slate-200">{{ canvasName }}</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">Double click to edit state</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
|
||||
const props = defineProps({
|
||||
canvasId: { type: String, default: 'demo' }
|
||||
})
|
||||
|
||||
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
|
||||
const canvasName = computed(() => props.canvasId)
|
||||
const elements = ref([])
|
||||
|
||||
const transformToFlowElements = (canvasData) => {
|
||||
const nodes = (canvasData.nodes || []).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : 'text',
|
||||
position: { x: node.x, y: node.y },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width,
|
||||
height: node.height
|
||||
},
|
||||
style: {
|
||||
width: `${node.width}px`,
|
||||
height: `${node.height}px`,
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: `1px solid ${node.style?.border || '#334155'}`,
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = (canvasData.edges || []).map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.fromNode,
|
||||
target: edge.toNode,
|
||||
sourceHandle: edge.fromSide,
|
||||
targetHandle: edge.toSide,
|
||||
animated: true,
|
||||
style: { stroke: '#475569', strokeWidth: 2 }
|
||||
}))
|
||||
|
||||
return [...nodes, ...edges]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
}
|
||||
|
||||
const updateNodeState = async ({ nodeId, state }) => {
|
||||
await apiUpdateNodeState(nodeId, state)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
const currentState = node.data?.state || 'pending'
|
||||
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
|
||||
updateNodeState({ nodeId: node.id, state: nextState })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="[
|
||||
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
|
||||
selected ? 'ring-2 ring-blue-500' : '',
|
||||
nodeStateClass
|
||||
]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- Header with icon and label -->
|
||||
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
|
||||
<span class="text-lg">{{ nodeIcon }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
|
||||
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
<!-- Кнопка запуска для спец-узлов -->
|
||||
<button
|
||||
v-if="['ai', 'webhook'].includes(type)"
|
||||
@click.stop="handleExecute"
|
||||
:disabled="isExecuting"
|
||||
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
|
||||
title="Запустить выполнение"
|
||||
>
|
||||
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
|
||||
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
<div class="text-slate-100 space-y-2 flex-1">
|
||||
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
|
||||
<div v-if="data.file" class="flex items-center gap-2">
|
||||
<span class="text-sm">📄</span>
|
||||
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
|
||||
</div>
|
||||
<div v-if="data.prompt" class="text-sm text-purple-300">
|
||||
<div class="font-medium mb-1">AI Prompt:</div>
|
||||
<div>{{ data.prompt }}</div>
|
||||
</div>
|
||||
<div v-if="data.url" class="flex items-center gap-2">
|
||||
<span class="text-sm">🔗</span>
|
||||
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
|
||||
</div>
|
||||
<div v-if="data.script_id" class="flex items-center gap-2">
|
||||
<span class="text-sm">🎭</span>
|
||||
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
|
||||
</div>
|
||||
<div v-if="data.assigned_to" class="flex items-center gap-2">
|
||||
<span class="text-sm">👤</span>
|
||||
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
|
||||
</div>
|
||||
<div v-if="data.alarm" class="flex items-center gap-2">
|
||||
<span class="text-sm">⏰</span>
|
||||
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection handles -->
|
||||
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||
import { computed, ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
type: { type: String, default: 'text' },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'execute'])
|
||||
const isExecuting = ref(false)
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (isExecuting.value) return
|
||||
isExecuting.value = true
|
||||
try {
|
||||
emit('execute', { id: props.id, type: props.type })
|
||||
} finally {
|
||||
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
|
||||
setTimeout(() => { isExecuting.value = false }, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
// Root element ref
|
||||
const rootEl = ref(null)
|
||||
|
||||
// Access Vue Flow API to update node dimensions
|
||||
const { updateNode } = useVueFlow()
|
||||
|
||||
// Measure and apply real height to Vue Flow node wrapper
|
||||
const applyNodeSize = () => {
|
||||
if (!rootEl.value) return
|
||||
const h = rootEl.value.scrollHeight
|
||||
if (h > 0) {
|
||||
updateNode(props.id, {
|
||||
style: { height: h + 'px' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => setTimeout(applyNodeSize, 50))
|
||||
setTimeout(applyNodeSize, 200)
|
||||
})
|
||||
|
||||
// Node style: fixed width from data, height auto to fit content
|
||||
const nodeStyle = computed(() => {
|
||||
const baseStyle = {
|
||||
backgroundColor: props.data.style?.background || '#1e293b',
|
||||
borderColor: props.data.style?.border || '#475569',
|
||||
width: `${props.data.width || 320}px`
|
||||
}
|
||||
|
||||
if (props.data.state === 'completed') {
|
||||
baseStyle.borderColor = '#10b981' // Green
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#3b82f6' // Blue
|
||||
} else if (props.data.state === 'review') {
|
||||
baseStyle.borderColor = '#a855f7' // Purple
|
||||
} else if (props.data.state === 'blocked') {
|
||||
baseStyle.borderColor = '#ef4444' // Red
|
||||
} else if (props.data.state === 'todo') {
|
||||
baseStyle.borderColor = '#64748b' // Slate
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
// Node type icon
|
||||
const nodeIcon = computed(() => {
|
||||
const icons = {
|
||||
text: '📝',
|
||||
file: '📄',
|
||||
task: '✅',
|
||||
ai: '🤖',
|
||||
webhook: '🔗',
|
||||
git: '🔄'
|
||||
}
|
||||
return icons[props.type] || '📝'
|
||||
})
|
||||
|
||||
// State handling
|
||||
const nodeStateClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
|
||||
if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
|
||||
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
|
||||
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
|
||||
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
|
||||
return ''
|
||||
})
|
||||
|
||||
const stateBadgeClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
|
||||
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
|
||||
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
|
||||
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✅ Done'
|
||||
if (state === 'in_progress') return '⏳ In Progress'
|
||||
if (state === 'review') return '👀 Review'
|
||||
if (state === 'blocked') return '⛔ Blocked'
|
||||
return '📋 To Do'
|
||||
})
|
||||
|
||||
// Priority handling
|
||||
const priorityBadgeClass = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
|
||||
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return '🔥 High'
|
||||
if (prio === 'low') return '🧊 Low'
|
||||
return '⚡ Med'
|
||||
})
|
||||
|
||||
// Get node title from text content
|
||||
const getNodeTitle = () => {
|
||||
if (props.data.text) {
|
||||
const lines = props.data.text.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('# ')) {
|
||||
return trimmed.replace('# ', '')
|
||||
}
|
||||
}
|
||||
// If no h1 found, use first non-empty line
|
||||
const firstLine = lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
|
||||
}
|
||||
if (props.data.file) return props.data.file
|
||||
return 'Node'
|
||||
}
|
||||
|
||||
// Format text with markdown support
|
||||
const formattedText = computed(() => {
|
||||
if (!props.data.text) return ''
|
||||
|
||||
let text = props.data.text
|
||||
|
||||
// Convert markdown headers
|
||||
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
|
||||
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
|
||||
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
|
||||
|
||||
// Convert bold text
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
|
||||
|
||||
// Convert italic text
|
||||
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
|
||||
|
||||
// Convert code blocks
|
||||
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
|
||||
|
||||
// Convert inline code
|
||||
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
|
||||
|
||||
// Convert lists
|
||||
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
|
||||
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
|
||||
|
||||
// Convert line breaks
|
||||
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
|
||||
text = text.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraphs
|
||||
if (!text.includes('<p>')) {
|
||||
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
|
||||
}
|
||||
|
||||
return text
|
||||
})
|
||||
|
||||
// Format alarm time
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
const date = new Date(alarm)
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
@click="installApp"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
|
||||
>
|
||||
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
|
||||
<span>Установить XFlow</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const deferredPrompt = ref(null)
|
||||
const isStandalone = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt.value = e
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt.value = null
|
||||
})
|
||||
})
|
||||
|
||||
const installApp = async () => {
|
||||
if (!deferredPrompt.value) {
|
||||
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
|
||||
return
|
||||
}
|
||||
|
||||
deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
console.log(`PWA: user choice outcome: ${outcome}`)
|
||||
deferredPrompt.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 3s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full overflow-auto p-3 rounded-lg cursor-pointer"
|
||||
:class="stateClass"
|
||||
@click="$emit('update-state', { nodeId: id, state: nextState })"
|
||||
>
|
||||
<div v-if="data.frontmatter?.alarm" class="text-xs text-yellow-400 mb-1 flex items-center gap-1">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatAlarm(data.frontmatter.alarm) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.frontmatter?.assigned_to" class="text-xs text-slate-400 mb-1">
|
||||
👤 {{ data.frontmatter.assigned_to }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.text" class="prose prose-invert prose-sm max-w-none" v-html="renderedHtml" />
|
||||
|
||||
<div v-else-if="data.file" class="text-sm text-blue-400">
|
||||
📄 {{ data.file }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ stateLabel }}
|
||||
</span>
|
||||
<span v-if="data.frontmatter?.script_id" class="text-xs text-purple-400">
|
||||
⚡ {{ data.frontmatter.script_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
isFile: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-state'])
|
||||
|
||||
const { renderMarkdown } = useCanvas()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
return renderMarkdown(props.data.text || '')
|
||||
})
|
||||
|
||||
const currentState = computed(() => props.data.state || 'pending')
|
||||
|
||||
const nextState = computed(() => {
|
||||
const s = currentState.value
|
||||
return s === 'pending' ? 'in_progress' : s === 'in_progress' ? 'completed' : 'pending'
|
||||
})
|
||||
|
||||
const stateClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-900/40 border-emerald-700'
|
||||
case 'in_progress': return 'bg-blue-900/40 border-blue-700'
|
||||
default: return 'bg-slate-800 border-slate-700'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-700 text-emerald-100'
|
||||
case 'in_progress': return 'bg-blue-700 text-blue-100'
|
||||
default: return 'bg-slate-700 text-slate-300'
|
||||
}
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return '✅ Выполнено'
|
||||
case 'in_progress': return '🔧 В работе'
|
||||
default: return '📋 Ожидает'
|
||||
}
|
||||
})
|
||||
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
return new Date(alarm).toLocaleString('ru-RU')
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
|
||||
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
|
||||
<span>🧙♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
|
||||
</h2>
|
||||
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 bg-slate-950/40">
|
||||
<button
|
||||
@click="wizardType = 'smart'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🎯 SMART Цель
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'grow'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🌱 GROW Сессия
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'dilts'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📐 Пирамида Дилтса
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'eisenhower'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📊 Матрица Эйзенхауэра
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body (Forms) -->
|
||||
<div class="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<!-- S.M.A.R.T Form -->
|
||||
<div v-if="wizardType === 'smart'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
|
||||
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
|
||||
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
|
||||
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
|
||||
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
|
||||
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- G.R.O.W Form -->
|
||||
<div v-if="wizardType === 'grow'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
|
||||
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь? Какой результат я хочу получить? Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
|
||||
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить? Какие ресурсы мне нужны? Кто мне может помочь? А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
|
||||
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация? Какие препятствия я вижу? Что мне мешает начать? Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
|
||||
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять? Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dilts Form -->
|
||||
<div v-if="wizardType === 'dilts'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
|
||||
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
|
||||
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
|
||||
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
|
||||
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
|
||||
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
|
||||
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eisenhower Form -->
|
||||
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
|
||||
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
|
||||
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="submitManual"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
|
||||
>
|
||||
Создать вручную
|
||||
</button>
|
||||
<button
|
||||
@click="submitAI"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
|
||||
>
|
||||
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>✨</span>
|
||||
Оптимизировать через AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
isSubmitting: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'submit-smart-manual',
|
||||
'submit-smart-ai',
|
||||
'submit-grow-manual',
|
||||
'submit-grow-ai',
|
||||
'submit-dilts-manual',
|
||||
'submit-dilts-ai',
|
||||
'submit-eisenhower-manual',
|
||||
'submit-eisenhower-ai'
|
||||
])
|
||||
|
||||
const wizardType = ref('smart')
|
||||
|
||||
const smartForm = ref({
|
||||
specific: '',
|
||||
measurable: '',
|
||||
achievable: '',
|
||||
relevant: '',
|
||||
deadline: ''
|
||||
})
|
||||
|
||||
const growForm = ref({
|
||||
goal: '',
|
||||
reality: '',
|
||||
options: '',
|
||||
wayForward: ''
|
||||
})
|
||||
|
||||
const diltsForm = ref({
|
||||
mission: '',
|
||||
identity: '',
|
||||
values: '',
|
||||
skills: '',
|
||||
actions: '',
|
||||
environment: ''
|
||||
})
|
||||
|
||||
const eisenhowerForm = ref({
|
||||
q1: '',
|
||||
q2: '',
|
||||
q3: '',
|
||||
q4: ''
|
||||
})
|
||||
|
||||
const resetForms = () => {
|
||||
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
|
||||
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
|
||||
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
|
||||
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
|
||||
}
|
||||
|
||||
const submitManual = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
emit('submit-smart-manual', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
emit('submit-grow-manual', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
emit('submit-dilts-manual', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
|
||||
}
|
||||
resetForms()
|
||||
}
|
||||
|
||||
const submitAI = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
if (!smartForm.value.specific.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание цели!')
|
||||
return
|
||||
}
|
||||
emit('submit-smart-ai', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
if (!growForm.value.goal.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы цель (Goal)!')
|
||||
return
|
||||
}
|
||||
emit('submit-grow-ai', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
if (!diltsForm.value.mission.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание миссии!')
|
||||
return
|
||||
}
|
||||
emit('submit-dilts-ai', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
|
||||
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
|
||||
return
|
||||
}
|
||||
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
|
||||
}
|
||||
}
|
||||
|
||||
// Expose reset in case parent wants to reset from outside
|
||||
defineExpose({ resetForms })
|
||||
</script>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
|
||||
:class="[isOpen ? 'h-80' : 'h-10']"
|
||||
>
|
||||
<!-- Handle / Toggle Bar -->
|
||||
<div
|
||||
@click="isOpen = !isOpen"
|
||||
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
|
||||
<span>📅</span>
|
||||
<span>Диаграмма Ганта</span>
|
||||
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
|
||||
Задач: {{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-slate-400 text-xs">
|
||||
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Body -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Task Titles Sidebar (Left) -->
|
||||
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
Задача
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
|
||||
title="Кликните для фокусировки на холсте"
|
||||
>
|
||||
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
|
||||
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
|
||||
</div>
|
||||
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
|
||||
Нет задач на холсте
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Chart (Right) -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<!-- Date Columns Header -->
|
||||
<div
|
||||
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
|
||||
ref="timelineHeader"
|
||||
@scroll="syncScrollLeft"
|
||||
>
|
||||
<div
|
||||
v-for="(day, idx) in timelineDays"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
>
|
||||
<span>{{ day.dayOfWeek }}</span>
|
||||
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Bars Body -->
|
||||
<div
|
||||
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
|
||||
ref="timelineBody"
|
||||
@scroll="syncScrollRight"
|
||||
>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
|
||||
>
|
||||
<!-- Timeline Grid Background Lines -->
|
||||
<div class="absolute inset-0 flex pointer-events-none">
|
||||
<div
|
||||
v-for="idx in timelineDays.length"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Task Gantt Bar -->
|
||||
<div
|
||||
v-if="hasValidDates(task)"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
|
||||
:class="getBarClasses(task)"
|
||||
:style="getBarStyles(task)"
|
||||
>
|
||||
<span class="truncate">{{ task.data.label }}</span>
|
||||
<!-- Tooltip on hover -->
|
||||
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
|
||||
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
|
||||
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
|
||||
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
|
||||
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
|
||||
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
|
||||
С: {{ formatDate(task.data.start_date) }}<br/>
|
||||
По: {{ formatDate(task.data.alarm) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
|
||||
Создайте задачи и задайте им даты начала/дедлайны
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineEmits(['select-node'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dayWidth = 60 // Width of one day column in pixels
|
||||
|
||||
const timelineHeader = ref(null)
|
||||
const timelineBody = ref(null)
|
||||
|
||||
// Scroll synchronization between header and body
|
||||
const syncScrollLeft = (e) => {
|
||||
if (timelineBody.value) {
|
||||
timelineBody.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
const syncScrollRight = (e) => {
|
||||
if (timelineHeader.value) {
|
||||
timelineHeader.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tasks
|
||||
const tasks = computed(() => {
|
||||
return props.elements.filter(el => el.type === 'task')
|
||||
})
|
||||
|
||||
// Calculate overall min/max dates
|
||||
const dateRange = computed(() => {
|
||||
let minDate = new Date()
|
||||
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
|
||||
|
||||
const taskDates = []
|
||||
tasks.value.forEach(t => {
|
||||
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
|
||||
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
|
||||
})
|
||||
|
||||
if (taskDates.length > 0) {
|
||||
minDate = new Date(Math.min(...taskDates))
|
||||
maxDate = new Date(Math.max(...taskDates))
|
||||
}
|
||||
|
||||
// Add 2 days padding to sides
|
||||
minDate.setDate(minDate.getDate() - 2)
|
||||
maxDate.setDate(maxDate.getDate() + 5)
|
||||
|
||||
// Cap timeline length at 30 days to prevent rendering overflow
|
||||
const diffTime = Math.abs(maxDate - minDate)
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
if (diffDays > 30) {
|
||||
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// Reset to midnight
|
||||
minDate.setHours(0, 0, 0, 0)
|
||||
maxDate.setHours(23, 59, 59, 999)
|
||||
|
||||
return { minDate, maxDate }
|
||||
})
|
||||
|
||||
// Generate list of days for timeline header
|
||||
const timelineDays = computed(() => {
|
||||
const days = []
|
||||
const current = new Date(dateRange.value.minDate)
|
||||
const end = dateRange.value.maxDate
|
||||
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||
|
||||
while (current <= end) {
|
||||
days.push({
|
||||
date: new Date(current),
|
||||
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
|
||||
dayOfWeek: ruDays[current.getDay()]
|
||||
})
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
// Check if task has valid start and end date
|
||||
const hasValidDates = (task) => {
|
||||
return !!(task.data.start_date || task.data.alarm)
|
||||
}
|
||||
|
||||
// Format single date for tooltips
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Не указано'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
|
||||
const getBarStyles = (task) => {
|
||||
const minTime = dateRange.value.minDate.getTime()
|
||||
const maxTime = dateRange.value.maxDate.getTime()
|
||||
const totalDuration = maxTime - minTime
|
||||
|
||||
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
|
||||
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
|
||||
|
||||
if (start > end) {
|
||||
// Correct order if user reversed dates
|
||||
const tmp = start
|
||||
start = end
|
||||
end = tmp
|
||||
}
|
||||
|
||||
// Boundary checks
|
||||
start = Math.max(start, minTime)
|
||||
end = Math.min(end, maxTime)
|
||||
|
||||
const leftPercent = ((start - minTime) / totalDuration) * 100
|
||||
const widthPercent = ((end - start) / totalDuration) * 100
|
||||
|
||||
// Calculate actual pixel offsets based on timeline width
|
||||
const totalDays = timelineDays.value.length
|
||||
const totalPixels = totalDays * dayWidth
|
||||
|
||||
const leftPx = (leftPercent / 100) * totalPixels
|
||||
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
|
||||
|
||||
return {
|
||||
left: `${leftPx}px`,
|
||||
width: `${widthPx}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Style colors based on task status
|
||||
const getBarClasses = (task) => {
|
||||
const state = task.data.state || 'todo'
|
||||
switch (state) {
|
||||
case 'completed':
|
||||
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
|
||||
case 'in_progress':
|
||||
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
|
||||
case 'review':
|
||||
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
|
||||
case 'blocked':
|
||||
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
|
||||
default:
|
||||
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return '✅'
|
||||
case 'in_progress': return '⏳'
|
||||
case 'review': return '👀'
|
||||
case 'blocked': return '⛔'
|
||||
default: return '📋'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return 'Завершено'
|
||||
case 'in_progress': return 'В работе'
|
||||
case 'review': return 'На проверке'
|
||||
case 'blocked': return 'Заблокировано'
|
||||
default: return 'К выполнению'
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'Высокий'
|
||||
case 'low': return 'Низкий'
|
||||
default: return 'Средний'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
|
||||
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ nt.icon }}</span>
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ nt.name }}</div>
|
||||
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userRole: { type: String, default: 'viewer' }
|
||||
})
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
|
||||
]
|
||||
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div v-if="node" class="space-y-3">
|
||||
<!-- SMART & GROW AI Coaching tools -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-indigo-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-indigo-400 flex items-center gap-1">
|
||||
<span>✨</span> AI Коуч-Ассистент
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
@click="$emit('apply-smart-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Преобразовать цель в формат SMART при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'smart'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🎯</span>
|
||||
<span>SMART</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-grow-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-teal-650 hover:bg-teal-600 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Построить цепочку GROW для проблемы при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'grow'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🌱</span>
|
||||
<span>GROW</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-dilts-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить проблему по логическим уровням Дилтса при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'dilts'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📐</span>
|
||||
<span>Дилтс</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-eisenhower-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить задачи по матрице Эйзенхауэра при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'eisenhower'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📊</span>
|
||||
<span>Эйзенхауэр</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Test Section -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-emerald-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-emerald-400 flex items-center justify-between cursor-pointer select-none" @click="showGoalTest = !showGoalTest">
|
||||
<span class="flex items-center gap-1">⚖️ Тест истинности цели</span>
|
||||
<span>{{ showGoalTest ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div v-if="showGoalTest" class="space-y-2 pt-1.5 border-t border-slate-800">
|
||||
<div v-for="(q, key) in goalTestQuestions" :key="key" class="space-y-1">
|
||||
<div class="flex justify-between items-center text-[9px] text-slate-400 font-medium leading-tight">
|
||||
<span :title="q.full" class="cursor-help border-b border-dashed border-slate-600">{{ q.short }}</span>
|
||||
<span class="text-emerald-400 font-bold shrink-0 ml-1">{{ goalTestScores[key] }}/10</span>
|
||||
</div>
|
||||
<input v-model.number="goalTestScores[key]" type="range" min="1" max="10" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-slate-800 flex flex-col gap-1.5">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="text-slate-400 font-semibold">Итоговый балл:</span>
|
||||
<span class="font-bold" :class="goalTestTotalScore >= 60 ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ goalTestTotalScore }} / 80
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[9px] p-1.5 rounded leading-normal border" :class="goalTestTotalScore >= 60 ? 'bg-emerald-950/40 text-emerald-300 border-emerald-800/30' : 'bg-red-950/40 text-red-300 border-red-800/30'">
|
||||
{{ goalTestTotalScore >= 60 ? '🟢 Цель истинно Ваша! Отличная мотивация.' : '🔴 Внимание! Возможны проблемы с мотивацией (менее 60 баллов).' }}
|
||||
</div>
|
||||
<button @click="writeGoalTestToNode" class="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-[10px] font-bold transition">
|
||||
✍️ Записать результат в узел
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
|
||||
<select v-model="node.type" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
|
||||
{{ nt.icon }} {{ nt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
|
||||
<input v-model="node.data.label" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.data.text !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
|
||||
<textarea v-model="node.data.text" @blur="handleChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
|
||||
<input v-model="node.data.file" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
|
||||
<select v-model="node.data.state" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="todo">📋 К выполнению (To Do)</option>
|
||||
<option value="in_progress">⏳ В работе (In Progress)</option>
|
||||
<option value="review">👀 На проверке (Review)</option>
|
||||
<option value="completed">✅ Завершено (Done)</option>
|
||||
<option value="blocked">⛔ Заблокировано (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
|
||||
<select v-model="node.data.priority" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="low">🧊 Низкий</option>
|
||||
<option value="medium">⚡ Средний</option>
|
||||
<option value="high">🔥 Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
|
||||
<select v-model="node.data.assigned_to" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="">Не назначено</option>
|
||||
<option v-for="u in usersList" :key="u.username" :value="u.username">
|
||||
{{ u.displayName || u.username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дата начала</label>
|
||||
<input v-model="node.data.start_date" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
|
||||
<input v-model="node.data.alarm" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
|
||||
<input v-model="node.data.url" @blur="handleChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
|
||||
<select v-model="node.data.method" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'ai'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.script_id !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
|
||||
<input v-model="node.data.script_id" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
|
||||
</div>
|
||||
<div v-if="node.type === 'file' || node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
|
||||
<select v-model="node.data.file" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none">
|
||||
<option value="">Не выбрано</option>
|
||||
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
|
||||
🗺️ {{ c.name || c.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.data.prompt !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
|
||||
<input v-model.number="node.data.width" @blur="handleChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
|
||||
<input v-model.number="node.data.height" @blur="handleChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500 text-center mt-8">
|
||||
Выберите узел для редактирования
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
usersList: { type: Array, default: () => [] },
|
||||
canvasesList: { type: Array, default: () => [] },
|
||||
isCoaching: { type: Boolean, default: false },
|
||||
currentCoachAction: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'apply-smart-ai', 'apply-grow-ai', 'apply-dilts-ai', 'apply-eisenhower-ai'])
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄' }
|
||||
]
|
||||
|
||||
const handleChange = () => {
|
||||
emit('change')
|
||||
}
|
||||
|
||||
// Goal Validation Test logic
|
||||
const showGoalTest = ref(false)
|
||||
const goalTestScores = ref({
|
||||
q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5
|
||||
})
|
||||
|
||||
const goalTestQuestions = {
|
||||
q1: { short: '1. Видение успеха', full: 'Насколько легко и с удовольствием я вижу себя, достигнувшим цель?' },
|
||||
q2: { short: '2. Страсть к процессу', full: 'Эта цель вызывает у меня страсть сама по себе? Мне нравится процесс ее достижения? (Без учета внешних наград)' },
|
||||
q3: { short: '3. Доп. польза', full: 'Достижение этой цели принесет мне дополнительную пользу в жизни/других сферах?' },
|
||||
q4: { short: '4. Баланс жизни', full: 'Смогу ли я достигнуть эту цель, не обделяя другие сферы жизни (время, силы, здоровье)?' },
|
||||
q5: { short: '5. Радость сразу', full: 'Я смогу получать радость и пользу от достижения цели сразу, по ходу процесса?' },
|
||||
q6: { short: '6. Зона контроля', full: 'Действия по достижению этой цели находятся в рамках моей личной собственной деятельности?' },
|
||||
q7: { short: '7. Поддержка окружения', full: 'Мое окружение будет поддерживать меня и мои усилия?' },
|
||||
q8: { short: '8. Ресурсы и автономия', full: 'У меня есть достаточно ресурсов для достижения? Достижима ли она без посторонней помощи?' }
|
||||
}
|
||||
|
||||
const goalTestTotalScore = computed(() => {
|
||||
return Object.values(goalTestScores.value).reduce((a, b) => a + b, 0)
|
||||
})
|
||||
|
||||
// Watch selected node and try to parse existing test scores if they exist
|
||||
watch(() => props.node?.id, () => {
|
||||
showGoalTest.value = false
|
||||
// Reset scores to default
|
||||
goalTestScores.value = { q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5 }
|
||||
|
||||
// Try to parse existing score from node text if present
|
||||
if (props.node?.data?.text) {
|
||||
const text = props.node.data.text
|
||||
const match = text.match(/Результат теста цели: (\d+)\/80/)
|
||||
if (match) {
|
||||
const qMatches = {
|
||||
q1: text.match(/Видение успеха:\s*(\d+)/),
|
||||
q2: text.match(/Страсть к процессу:\s*(\d+)/),
|
||||
q3: text.match(/Доп. польза:\s*(\d+)/) || text.match(/Дополнительная польза:\s*(\d+)/),
|
||||
q4: text.match(/Баланс жизни:\s*(\d+)/),
|
||||
q5: text.match(/Радость сразу:\s*(\d+)/),
|
||||
q6: text.match(/Зона контроля:\s*(\d+)/) || text.match(/Личная зона контроля:\s*(\d+)/),
|
||||
q7: text.match(/Поддержка окружения:\s*(\d+)/),
|
||||
q8: text.match(/Ресурсы и автономия:\s*(\d+)/) || text.match(/Хватает ресурсов:\s*(\d+)/)
|
||||
}
|
||||
for (const [key, m] of Object.entries(qMatches)) {
|
||||
if (m) goalTestScores.value[key] = parseInt(m[1])
|
||||
}
|
||||
showGoalTest.value = true
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const writeGoalTestToNode = () => {
|
||||
if (!props.node) return
|
||||
|
||||
const text = props.node.data.text || props.node.data.label || ''
|
||||
|
||||
// Strip previous test results if present
|
||||
let cleanText = text.split('\n\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
cleanText = cleanText.split('\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
|
||||
const verdict = goalTestTotalScore.value >= 60 ? 'Ваша цель' : 'Чужая цель / Риск потери мотивации'
|
||||
const testMarkdown = `\n\n---\n### ⚖️ Результат теста цели: ${goalTestTotalScore.value}/80 (${verdict})\n` +
|
||||
`* **Видение успеха:** ${goalTestScores.value.q1}/10\n` +
|
||||
`* **Страсть к процессу:** ${goalTestScores.value.q2}/10\n` +
|
||||
`* **Доп. польза:** ${goalTestScores.value.q3}/10\n` +
|
||||
`* **Баланс жизни:** ${goalTestScores.value.q4}/10\n` +
|
||||
`* **Радость сразу:** ${goalTestScores.value.q5}/10\n` +
|
||||
`* **Зона контроля:** ${goalTestScores.value.q6}/10\n` +
|
||||
`* **Поддержка окружения:** ${goalTestScores.value.q7}/10\n` +
|
||||
`* **Ресурсы и автономия:** ${goalTestScores.value.q8}/10`
|
||||
|
||||
props.node.data.text = cleanText + testMarkdown
|
||||
handleChange()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
unlockedAt?: string
|
||||
}
|
||||
|
||||
export const useAchievements = () => {
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const stats = ref({ total: 0, completedTasks: 0 })
|
||||
const pending = ref(false)
|
||||
|
||||
const loadAchievements = async () => {
|
||||
pending.value = true
|
||||
try {
|
||||
const auth = useAuth()
|
||||
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
achievements.value = data.achievements || []
|
||||
stats.value = data.stats || { total: 0, completedTasks: 0 }
|
||||
} catch (e) {
|
||||
console.error('Failed to load achievements:', e)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
achievements,
|
||||
stats,
|
||||
pending,
|
||||
loadAchievements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const token = useCookie<string | null>('xflow_token', {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
sameSite: 'lax'
|
||||
})
|
||||
|
||||
const isInitializing = useState('auth_initializing', () => true)
|
||||
|
||||
const user = computed<User | null>(() => {
|
||||
if (!token.value) return null
|
||||
try {
|
||||
return jwtDecode<User>(token.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
const googleSignIn = async () => {
|
||||
const auth = getAuth()
|
||||
const provider = new GoogleAuthProvider()
|
||||
const result = await signInWithPopup(auth, provider)
|
||||
const idToken = await result.user.getIdToken()
|
||||
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
|
||||
token.value = res.token
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
await signOut(auth)
|
||||
} catch (e) {
|
||||
console.warn('[Firebase] Sign out failed or not initialized', e)
|
||||
} finally {
|
||||
token.value = null
|
||||
navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const initAuth = () => {
|
||||
if (process.server) return
|
||||
|
||||
const auth = getAuth()
|
||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
||||
if (firebaseUser && !token.value) {
|
||||
try {
|
||||
const idToken = await firebaseUser.getIdToken()
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
token.value = res.token
|
||||
|
||||
if (useRoute().path === '/login') {
|
||||
await navigateTo('/')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore backend auth from Firebase', e)
|
||||
}
|
||||
}
|
||||
isInitializing.value = false
|
||||
})
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isInitializing,
|
||||
googleSignIn,
|
||||
logout,
|
||||
initAuth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
text?: string
|
||||
file?: string
|
||||
frontmatter?: Record<string, any>
|
||||
state?: 'pending' | 'in_progress' | 'completed'
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
fromNode: string
|
||||
fromSide: string
|
||||
toNode: string
|
||||
toSide: string
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
id?: string
|
||||
name?: string
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
}
|
||||
|
||||
export const useCanvas = () => {
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
|
||||
const renderMarkdown = (text: string): string => {
|
||||
return md.render(text || '')
|
||||
}
|
||||
|
||||
const parseCanvasJson = (json: any): CanvasData => {
|
||||
if (!json) return { nodes: [], edges: [] }
|
||||
const nodes = (json.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
width: n.width || 300,
|
||||
height: n.height || 200,
|
||||
text: n.text,
|
||||
label: n.label,
|
||||
file: n.file,
|
||||
frontmatter: n.frontmatter,
|
||||
state: n.state || 'pending',
|
||||
priority: n.priority,
|
||||
assigned_to: n.assigned_to,
|
||||
alarm: n.alarm,
|
||||
script_id: n.script_id,
|
||||
prompt: n.prompt,
|
||||
url: n.url,
|
||||
method: n.method,
|
||||
repo: n.repo,
|
||||
branch: n.branch,
|
||||
message: n.message,
|
||||
style: n.style || {}
|
||||
}))
|
||||
|
||||
const edges = (json.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
fromNode: e.fromNode || e.source,
|
||||
fromSide: e.fromSide || 'right',
|
||||
toNode: e.toNode || e.target,
|
||||
toSide: e.toSide || 'left'
|
||||
}))
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
nodes,
|
||||
edges
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const auth = useAuth()
|
||||
const token = auth.token?.value
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
const loadCanvas = async (id: string): Promise<{ canvas: CanvasData, role: string }> => {
|
||||
const data = await $fetch<{ canvas: any, role: string }>(`/api/canvas/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return {
|
||||
canvas: parseCanvasJson(data.canvas),
|
||||
role: data.role
|
||||
}
|
||||
}
|
||||
|
||||
const updateNodeState = async (nodeId: string, state: string) => {
|
||||
return await $fetch(`/api/nodes/${nodeId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: { state }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderMarkdown,
|
||||
parseCanvasJson,
|
||||
loadCanvas,
|
||||
updateNodeState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Composable for Git synchronization functionality
|
||||
interface GitRepository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
url: string
|
||||
cloneUrl: string
|
||||
isPrivate: boolean
|
||||
type: 'personal' | 'group'
|
||||
ownerId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const useGitSync = () => {
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user)
|
||||
|
||||
const apiFetch = (url: string, opts: any = {}) => $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const lastSyncTime = ref<string | null>(null)
|
||||
const syncStatus = ref<'idle' | 'syncing' | 'success' | 'error'>('idle')
|
||||
const repository = ref<GitRepository | null>(null)
|
||||
|
||||
// Auto-sync canvas changes
|
||||
const autoSyncCanvas = async (canvasId: string, canvasData: any) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: `Auto-sync: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
|
||||
// Show success notification
|
||||
console.log('Canvas synchronized with Git:', response.message)
|
||||
} else {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Git sync failed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Auto-sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
// Reset status after delay
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create Git repository for canvas
|
||||
const createRepository = async (canvasId: string, type: 'personal' | 'group' = 'personal') => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'init'
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
repository.value = response.repository
|
||||
console.log('Git repository created:', response.message)
|
||||
return response.repository
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create repository:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync
|
||||
const manualSync = async (canvasId: string, commitMessage?: string) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: commitMessage || `Ручная синхронизация: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
console.log('Manual sync completed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Manual sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if canvas has repository
|
||||
const checkRepository = async (canvasId: string) => {
|
||||
if (!canvasId) return null
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/git/repository/${canvasId}`) as any
|
||||
repository.value = response.repository
|
||||
return response.repository
|
||||
} catch (error) {
|
||||
repository.value = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status color
|
||||
const syncStatusColor = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return 'text-yellow-400'
|
||||
case 'success': return 'text-green-400'
|
||||
case 'error': return 'text-red-400'
|
||||
default: return 'text-slate-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync status icon
|
||||
const syncStatusIcon = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return '⟳'
|
||||
case 'success': return '✓'
|
||||
case 'error': return '✗'
|
||||
default: return '⚡'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isSyncing: readonly(isSyncing),
|
||||
lastSyncTime: readonly(lastSyncTime),
|
||||
syncStatus: readonly(syncStatus),
|
||||
repository: readonly(repository),
|
||||
syncStatusColor,
|
||||
syncStatusIcon,
|
||||
autoSyncCanvas,
|
||||
createRepository,
|
||||
manualSync,
|
||||
checkRepository
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const usePush = () => {
|
||||
const isSupported = ref(false)
|
||||
const subscription = ref<PushSubscription | null>(null)
|
||||
|
||||
const checkSupport = () => {
|
||||
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
|
||||
}
|
||||
|
||||
const getRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (!('serviceWorker' in navigator)) return null
|
||||
return navigator.serviceWorker.ready
|
||||
}
|
||||
|
||||
const subscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
subscription.value = existing
|
||||
await sendSubscriptionToServer(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
// In production, get VAPID public key from server
|
||||
const vapidKey = await $fetch<string>('/api/push/vapid-public-key').catch(() => null)
|
||||
const applicationServerKey = vapidKey
|
||||
? (urlBase64ToUint8Array(vapidKey) as unknown as ArrayBuffer)
|
||||
: undefined
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
|
||||
subscription.value = sub
|
||||
await sendSubscriptionToServer(sub)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Push subscription failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendSubscriptionToServer = async (sub: PushSubscription) => {
|
||||
await $fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: { subscription: sub.toJSON() }
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
await existing.unsubscribe()
|
||||
subscription.value = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const showLocalNotification = (title: string, options?: NotificationOptions) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
reg.showNotification(title, {
|
||||
icon: '/pwa-192x192.svg',
|
||||
badge: '/favicon.svg',
|
||||
...options as any
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
if (!('Notification' in window)) return false
|
||||
const permission = await Notification.requestPermission()
|
||||
return permission === 'granted'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkSupport()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
subscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
showLocalNotification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Composable for tracking unsaved changes and preventing accidental data loss
|
||||
export const useUnsavedChanges = () => {
|
||||
const isDirty = ref(false)
|
||||
const lastSavedData = ref<string | null>(null)
|
||||
const currentData = ref<string | null>(null)
|
||||
|
||||
// Track changes by comparing current data with last saved data
|
||||
const trackChanges = (newData: any) => {
|
||||
currentData.value = JSON.stringify(newData)
|
||||
|
||||
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
|
||||
} else if (!lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
|
||||
} else {
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Not dirty, data is identical')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark data as saved
|
||||
const markAsSaved = (data: any) => {
|
||||
lastSavedData.value = JSON.stringify(data)
|
||||
currentData.value = lastSavedData.value
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
|
||||
}
|
||||
|
||||
// Reset dirty state
|
||||
const resetDirty = () => {
|
||||
isDirty.value = false
|
||||
lastSavedData.value = currentData.value
|
||||
}
|
||||
|
||||
// Warning before leaving page
|
||||
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
||||
if (isDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?'
|
||||
return event.returnValue
|
||||
}
|
||||
}
|
||||
|
||||
// Setup warning on mount
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
return {
|
||||
isDirty: readonly(isDirty),
|
||||
trackChanges,
|
||||
markAsSaved,
|
||||
resetDirty
|
||||
}
|
||||
}
|
||||