From eb4e71ee77e43e42a9d3925cfbd867ce8fdb70a4 Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 19 May 2026 17:48:35 +0500 Subject: [PATCH] infinity serch --- .gitignore | 2 + api/bd/models.py | 1 + api/main.py | 2 + ...3d4e5f6_add_strict_filter_to_vk_sources.py | 26 +++ api/route/admin_system.py | 115 +++++++++++ api/route/admin_vk.py | 36 ++-- api/vk_parser.py | 36 +++- docker-compose.external.yml | 61 ++++++ docker-compose.yml | 84 +++++++- web-react/src/App.jsx | 2 + web-react/src/api/index.js | 15 +- web-react/src/components/AdminLayout.jsx | 1 + web-react/src/components/FilterBar.jsx | 2 +- web-react/src/pages/NewsFeed.jsx | 185 +++++++++++------- web-react/src/pages/admin/SystemStatus.jsx | 144 ++++++++++++++ web-react/src/pages/admin/VkSources.jsx | 50 ++++- web/editor-ui/src/App.jsx | 82 ++++---- 17 files changed, 694 insertions(+), 150 deletions(-) create mode 100644 api/migrations/versions/a1b2c3d4e5f6_add_strict_filter_to_vk_sources.py create mode 100644 api/route/admin_system.py create mode 100644 docker-compose.external.yml create mode 100644 web-react/src/pages/admin/SystemStatus.jsx diff --git a/.gitignore b/.gitignore index c5a1982..0806c42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # ── Secrets ──────────────────────────────────────────────────────────────── .env +.env.internal +.env.external # .env.example is intentionally committed as a setup template # ── Local data volumes (postgres / redis / minio) ───────────────────────── diff --git a/api/bd/models.py b/api/bd/models.py index 86143ae..3494240 100644 --- a/api/bd/models.py +++ b/api/bd/models.py @@ -83,5 +83,6 @@ class VkSource(Base): group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории enabled: Mapped[bool] = mapped_column(Boolean, default=True) + strict_filter: Mapped[bool] = mapped_column(Boolean, default=True) # True = только посты с тегами last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/api/main.py b/api/main.py index b34dd2c..d6d7b9e 100644 --- a/api/main.py +++ b/api/main.py @@ -17,6 +17,7 @@ from route.admin_articles import router as articles_router from route.admin_categories import router as categories_router from route.admin_media import router as media_router from route.admin_vk import router as vk_router +from route.admin_system import router as system_router import scheduler DOCS_USER = os.getenv("DOCS_USERNAME", "admin") @@ -102,6 +103,7 @@ app.include_router(articles_router, prefix="/admin/articles", tags=["admin-art app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"]) app.include_router(media_router, prefix="/admin/media", tags=["admin-media"]) app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"]) +app.include_router(system_router, prefix="/admin/system", tags=["admin-system"]) if __name__ == "__main__": diff --git a/api/migrations/versions/a1b2c3d4e5f6_add_strict_filter_to_vk_sources.py b/api/migrations/versions/a1b2c3d4e5f6_add_strict_filter_to_vk_sources.py new file mode 100644 index 0000000..3971a9c --- /dev/null +++ b/api/migrations/versions/a1b2c3d4e5f6_add_strict_filter_to_vk_sources.py @@ -0,0 +1,26 @@ +"""add strict_filter to vk_sources + +Revision ID: a1b2c3d4e5f6 +Revises: 25d6ed4a524a +Create Date: 2026-05-19 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '25d6ed4a524a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('vk_sources', + sa.Column('strict_filter', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + op.drop_column('vk_sources', 'strict_filter') diff --git a/api/route/admin_system.py b/api/route/admin_system.py new file mode 100644 index 0000000..14562f2 --- /dev/null +++ b/api/route/admin_system.py @@ -0,0 +1,115 @@ +import os +import time +import asyncio +from urllib.parse import urlparse +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from bd.database import get_db, get_redis, DATABASE_URL, REDIS_URL +from route.deps import require_admin + +router = APIRouter() + +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media") +MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000") + + +def _parse_db_url(url: str) -> dict: + """Вытащить host/port/db/user из DATABASE_URL без пароля.""" + try: + # postgresql+asyncpg://user:pass@host:port/db + p = urlparse(url.replace("+asyncpg", "")) + return { + "host": p.hostname or "?", + "port": p.port or 5432, + "database": (p.path or "/").lstrip("/") or "?", + "user": p.username or "?", + } + except Exception: + return {"host": "?", "port": 5432, "database": "?", "user": "?"} + + +def _parse_redis_url(url: str) -> dict: + """Вытащить host/port из REDIS_URL без пароля.""" + try: + p = urlparse(url) + return { + "host": p.hostname or "?", + "port": p.port or 6379, + "db": (p.path or "/0").lstrip("/") or "0", + } + except Exception: + return {"host": "?", "port": 6379, "db": "0"} + + +async def _check_postgres(db: AsyncSession) -> dict: + info = _parse_db_url(DATABASE_URL) + t0 = time.monotonic() + try: + result = await db.execute(text("SELECT version()")) + version = result.scalar() or "" + latency_ms = round((time.monotonic() - t0) * 1000, 1) + # Извлекаем короткую версию: "PostgreSQL 15.3 ..." + short_ver = version.split(",")[0] if version else "?" + return {"ok": True, "latency_ms": latency_ms, "version": short_ver, **info} + except Exception as e: + return {"ok": False, "error": str(e), "latency_ms": None, **info} + + +async def _check_redis() -> dict: + info = _parse_redis_url(REDIS_URL) + t0 = time.monotonic() + try: + redis = await get_redis() + pong = await redis.ping() + latency_ms = round((time.monotonic() - t0) * 1000, 1) + server_info = await redis.info("server") + version = server_info.get("redis_version", "?") + return {"ok": bool(pong), "latency_ms": latency_ms, "version": f"Redis {version}", **info} + except Exception as e: + return {"ok": False, "error": str(e), "latency_ms": None, **info} + + +async def _check_minio() -> dict: + t0 = time.monotonic() + try: + from minio import Minio + loop = asyncio.get_event_loop() + client = Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False) + exists = await loop.run_in_executor(None, client.bucket_exists, MINIO_BUCKET) + latency_ms = round((time.monotonic() - t0) * 1000, 1) + return { + "ok": True, + "latency_ms": latency_ms, + "endpoint": MINIO_ENDPOINT, + "bucket": MINIO_BUCKET, + "public_url": MINIO_PUBLIC_URL, + "bucket_exists": exists, + } + except Exception as e: + return { + "ok": False, + "error": str(e), + "latency_ms": None, + "endpoint": MINIO_ENDPOINT, + "bucket": MINIO_BUCKET, + "public_url": MINIO_PUBLIC_URL, + } + + +@router.get("/status") +async def system_status(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)): + pg, redis, minio = await asyncio.gather( + _check_postgres(db), + _check_redis(), + _check_minio(), + ) + return { + "postgres": pg, + "redis": redis, + "minio": minio, + } diff --git a/api/route/admin_vk.py b/api/route/admin_vk.py index 86b13f4..23972e8 100644 --- a/api/route/admin_vk.py +++ b/api/route/admin_vk.py @@ -15,16 +15,18 @@ class SourceIn(BaseModel): group_id: str group_name: str enabled: bool = True + strict_filter: bool = True def _source_dict(s: VkSource) -> dict: return { - "id": str(s.id), - "group_id": s.group_id, - "group_name": s.group_name, - "enabled": s.enabled, - "last_run": s.last_run.isoformat() if s.last_run else None, - "created_at": s.created_at.isoformat(), + "id": str(s.id), + "group_id": s.group_id, + "group_name": s.group_name, + "enabled": s.enabled, + "strict_filter": s.strict_filter, + "last_run": s.last_run.isoformat() if s.last_run else None, + "created_at": s.created_at.isoformat(), } @@ -56,7 +58,7 @@ async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none() if exists: raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть") - source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled) + source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled, strict_filter=data.strict_filter) db.add(source) await db.commit() await db.refresh(source) @@ -68,9 +70,10 @@ async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depen source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none() if not source: raise HTTPException(status_code=404, detail="Источник не найден") - source.group_id = _clean_group_id(data.group_id) - source.group_name = data.group_name - source.enabled = data.enabled + source.group_id = _clean_group_id(data.group_id) + source.group_name = data.group_name + source.enabled = data.enabled + source.strict_filter = data.strict_filter await db.commit() await db.refresh(source) return _source_dict(source) @@ -94,20 +97,25 @@ async def vk_manual_import(_: str = Depends(require_admin)): @router.post("/import/history") -async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)): - background_tasks.add_task(run_history_import) - return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"} +async def vk_history_import_all( + background_tasks: BackgroundTasks, + since_days: int = 365, + _: str = Depends(require_admin), +): + background_tasks.add_task(run_history_import, since_days=since_days) + return {"ok": True, "message": f"Исторический импорт всех групп запущен в фоне (глубина {since_days} дн.)"} @router.post("/import/history/{source_id}") async def vk_history_import_one( source_id: str, background_tasks: BackgroundTasks, + since_days: int = 365, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none() if not source: raise HTTPException(status_code=404, detail="Источник не найден") - background_tasks.add_task(run_history_import, group_id=source.group_id) + background_tasks.add_task(run_history_import, group_id=source.group_id, since_days=since_days) return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"} diff --git a/api/vk_parser.py b/api/vk_parser.py index 03a7f46..c6654aa 100644 --- a/api/vk_parser.py +++ b/api/vk_parser.py @@ -359,6 +359,12 @@ async def _slug_exists(slug: str, db: AsyncSession) -> bool: return r.scalar_one_or_none() is not None +async def _vk_post_exists(owner_id: int, post_id: int, db: AsyncSession) -> bool: + source_url = f"https://vk.com/wall{owner_id}_{post_id}" + r = await db.execute(select(Article.id).where(Article.source_url == source_url)) + return r.scalar_one_or_none() is not None + + async def _get_or_create_category(name: str, db: AsyncSession) -> Category: s = slugify(name) cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none() @@ -381,21 +387,39 @@ async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag: # ── Post processing ─────────────────────────────────────────────────────────── -async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool: +async def _process_post(post: dict, category_name: str, db: AsyncSession, strict_filter: bool = True) -> bool: owner_id = post["owner_id"] post_id = post["id"] vk_slug = _vk_slug(owner_id, post_id) - if await _slug_exists(vk_slug, db): + if await _vk_post_exists(owner_id, post_id, db): return False text = post.get("text", "") attachments = post.get("attachments", []) ts = post["date"] - # Фильтр: импортируем только посты с разрешёнными тегами + # Обработка репостов (copy_history) + copy_history = post.get("copy_history", []) + if copy_history: + original = copy_history[0] + orig_gid = str(abs(original.get("owner_id", 0))) + in_db = (await db.execute( + select(VkSource.id).where(VkSource.group_id == orig_gid) + )).scalar_one_or_none() + if in_db: + # Оригинал придёт сам из своей группы — пропускаем + return False + # Берём содержимое оригинала; комментарий репостера добавляем в начало + orig_text = original.get("text", "") + orig_atts = original.get("attachments", []) + text = (text + "\n" + orig_text).strip() if text else orig_text + attachments = orig_atts if orig_atts else attachments + tag_names = _detect_tags(text) - if not tag_names: + # strict_filter=True: пропускаем пост если нет тегов + # strict_filter=False: импортируем всё, теги назначаем если найдены + if strict_filter and not tag_names: return False # Заранее генерируем UUID и slug — используются в путях MinIO @@ -648,7 +672,7 @@ async def run_import() -> dict: batch_new = 0 for post in posts: try: - ok = await _process_post(post, source.group_name, db) + ok = await _process_post(post, source.group_name, db, source.strict_filter) if ok: imported += 1; grp_new += 1; batch_new += 1 else: @@ -721,7 +745,7 @@ async def run_history_import(group_id: str | None = None, since_days: int = 365) hit_cutoff = True continue try: - ok = await _process_post(post, source.group_name, db) + ok = await _process_post(post, source.group_name, db, source.strict_filter) if ok: imported += 1; grp_new += 1; batch_new += 1 else: diff --git a/docker-compose.external.yml b/docker-compose.external.yml new file mode 100644 index 0000000..9982c43 --- /dev/null +++ b/docker-compose.external.yml @@ -0,0 +1,61 @@ +services: + + # ── FastAPI backend ──────────────────────────────────────────────────────── + api: + build: + context: ./api + dockerfile: Dockerfile + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + REDIS_URL: ${REDIS_URL} + JWT_SECRET: ${JWT_SECRET} + MINIO_ENDPOINT: ${MINIO_ENDPOINT} + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} + MINIO_BUCKET: ${MINIO_BUCKET:-news-media} + MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL} + DOCS_USERNAME: ${DOCS_USERNAME:-admin} + DOCS_PASSWORD: ${DOCS_PASSWORD:-admin} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} + VK_ACCESS_TOKEN: ${VK_ACCESS_TOKEN:-} + VK_IMPORT_STATUS: ${VK_IMPORT_STATUS:-published} + VK_POSTS_PER_RUN: ${VK_POSTS_PER_RUN:-100} + VK_IMPORT_HOUR: ${VK_IMPORT_HOUR:-6} + VK_IMPORT_MINUTE: ${VK_IMPORT_MINUTE:-0} + ports: + - "8000:8000" + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + networks: + - news_net + + # ── React SPA + TipTap editor + nginx ───────────────────────────────────── + web: + build: + context: . + dockerfile: web-react/Dockerfile + restart: unless-stopped + ports: + - "80:80" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + networks: + - news_net + + +networks: + news_net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 634eff9..13851b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,82 @@ services: + # ── PostgreSQL ──────────────────────────────────────────────────────────── + db: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: news + POSTGRES_USER: news_admin + POSTGRES_PASSWORD: ${NEWS_DB_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U news_admin -d news"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - news_net + + # ── Redis ───────────────────────────────────────────────────────────────── + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - news_net + + # ── MinIO ───────────────────────────────────────────────────────────────── + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY} + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD-SHELL", "mc ready local || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + networks: + - news_net + # ── FastAPI backend ──────────────────────────────────────────────────────── api: build: context: ./api dockerfile: Dockerfile restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy environment: - DATABASE_URL: ${DATABASE_URL} - REDIS_URL: ${REDIS_URL} + DATABASE_URL: postgresql+asyncpg://news_admin:${NEWS_DB_PASSWORD}@db:5432/news + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 JWT_SECRET: ${JWT_SECRET} - MINIO_ENDPOINT: ${MINIO_ENDPOINT:-192.168.1.21:9000} - MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} - MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + MINIO_ENDPOINT: minio:9000 + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} MINIO_BUCKET: ${MINIO_BUCKET:-news-media} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} DOCS_USERNAME: ${DOCS_USERNAME:-admin} @@ -47,7 +111,7 @@ services: api: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ || exit 1"] interval: 10s timeout: 5s retries: 5 @@ -56,7 +120,11 @@ services: - news_net +volumes: + db_data: + redis_data: + minio_data: + networks: news_net: - external: true - name: news_net + driver: bridge diff --git a/web-react/src/App.jsx b/web-react/src/App.jsx index 56ef133..181044b 100644 --- a/web-react/src/App.jsx +++ b/web-react/src/App.jsx @@ -13,6 +13,7 @@ import ArticleEditor from './pages/admin/ArticleEditor' import Categories from './pages/admin/Categories' import MediaLibrary from './pages/admin/MediaLibrary' import VkSources from './pages/admin/VkSources' +import SystemStatus from './pages/admin/SystemStatus' function PrivateRoute({ children }) { const { isAuth } = useAuth() @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/web-react/src/api/index.js b/web-react/src/api/index.js index 21657b1..c104682 100644 --- a/web-react/src/api/index.js +++ b/web-react/src/api/index.js @@ -97,11 +97,11 @@ export const adminVkStatus = token => req('GET', 'admin/vk/status', token) export const adminVkListSources = token => req('GET', 'admin/vk/sources', token) -export const adminVkAddSource = (token, group_id, group_name, enabled = true) => - req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled }) +export const adminVkAddSource = (token, group_id, group_name, enabled = true, strict_filter = true) => + req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled, strict_filter }) -export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) => - req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled }) +export const adminVkUpdateSource = (token, id, group_id, group_name, enabled, strict_filter = true) => + req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled, strict_filter }) export const adminVkDeleteSource = (token, id) => req('DELETE', `admin/vk/sources/${id}`, token) @@ -109,5 +109,8 @@ export const adminVkDeleteSource = (token, id) => export const adminVkImport = token => req('POST', 'admin/vk/import', token) -export const adminVkImportHistory = (token, sourceId) => - req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token) +export const adminVkImportHistory = (token, sourceId, sinceDays = 365) => + req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}?since_days=${sinceDays}`, token) + +export const adminSystemStatus = token => + req('GET', 'admin/system/status', token) diff --git a/web-react/src/components/AdminLayout.jsx b/web-react/src/components/AdminLayout.jsx index c0d99db..93fd317 100644 --- a/web-react/src/components/AdminLayout.jsx +++ b/web-react/src/components/AdminLayout.jsx @@ -9,6 +9,7 @@ const NAV = [ { to: '/admin/categories', label: 'Категории', icon: '🏷️' }, { to: '/admin/media', label: 'Медиа', icon: '🖼️' }, { to: '/admin/vk', label: 'ВКонтакте', icon: '📥' }, + { to: '/admin/system', label: 'Сервисы', icon: '🖥️' }, ] export default function AdminLayout({ children, title }) { diff --git a/web-react/src/components/FilterBar.jsx b/web-react/src/components/FilterBar.jsx index 0181470..87c077b 100644 --- a/web-react/src/components/FilterBar.jsx +++ b/web-react/src/components/FilterBar.jsx @@ -9,7 +9,7 @@ const DATE_PRESETS = [ ] export default function FilterBar({ categories, tags, filters, onChange }) { - const set = key => val => onChange({ ...filters, [key]: val, page: 1 }) + const set = key => val => onChange({ ...filters, [key]: val }) return (
diff --git a/web-react/src/pages/NewsFeed.jsx b/web-react/src/pages/NewsFeed.jsx index f4db6af..45e51ce 100644 --- a/web-react/src/pages/NewsFeed.jsx +++ b/web-react/src/pages/NewsFeed.jsx @@ -19,12 +19,18 @@ function presetToDateFrom(preset) { export default function NewsFeed() { const { dark, toggle } = useTheme() - const [articles, setArticles] = useState([]) - const [total, setTotal] = useState(0) + const [articles, setArticles] = useState([]) + const [total, setTotal] = useState(0) const [categories, setCategories] = useState([]) - const [tags, setTags] = useState([]) - const [loading, setLoading] = useState(true) - const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 }) + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '' }) + + const offsetRef = useRef(0) + const busyRef = useRef(false) + const sentinelRef = useRef(null) // Поиск с debounce const [searchInput, setSearchInput] = useState('') @@ -35,19 +41,14 @@ export default function NewsFeed() { const val = e.target.value setSearchInput(val) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => { - setSearchQuery(val.trim()) - setFilters(f => ({ ...f, page: 1 })) - }, 400) + debounceRef.current = setTimeout(() => setSearchQuery(val.trim()), 400) } const clearSearch = () => { setSearchInput('') setSearchQuery('') - setFilters(f => ({ ...f, page: 1 })) } - // Модальное окно const [modalArticle, setModalArticle] = useState(null) useEffect(() => { @@ -57,30 +58,71 @@ export default function NewsFeed() { }).catch(() => {}) }, []) - const loadNews = useCallback(async () => { - setLoading(true) - try { - const params = { - limit: PAGE_SIZE, - offset: (filters.page - 1) * PAGE_SIZE, - } - if (filters.category) params.category = filters.category - if (filters.tag) params.tag = filters.tag - const df = presetToDateFrom(filters.datePreset) - if (df) params.date_from = df - if (searchQuery) params.q = searchQuery - - const data = await getNews(params) - setArticles(data.items ?? []) - setTotal(data.total ?? 0) - } catch { - setArticles([]) - } finally { - setLoading(false) - } + const buildParams = useCallback((offset) => { + const params = { limit: PAGE_SIZE, offset } + if (filters.category) params.category = filters.category + if (filters.tag) params.tag = filters.tag + const df = presetToDateFrom(filters.datePreset) + if (df) params.date_from = df + if (searchQuery) params.q = searchQuery + return params }, [filters, searchQuery]) - useEffect(() => { loadNews() }, [loadNews]) + // Сброс и первая загрузка при смене фильтров/поиска + useEffect(() => { + let cancelled = false + offsetRef.current = 0 + busyRef.current = true + setLoading(true) + setArticles([]) + setHasMore(false) + + getNews(buildParams(0)).then(data => { + if (cancelled) return + setArticles(data.items ?? []) + setTotal(data.total ?? 0) + setHasMore(data.has_more ?? false) + offsetRef.current = PAGE_SIZE + }).catch(() => { + if (!cancelled) setArticles([]) + }).finally(() => { + if (!cancelled) { + setLoading(false) + busyRef.current = false + } + }) + + return () => { cancelled = true } + }, [buildParams]) + + // Подгрузка следующего батча + const loadMore = useCallback(async () => { + if (busyRef.current || !hasMore) return + busyRef.current = true + setLoadingMore(true) + try { + const data = await getNews(buildParams(offsetRef.current)) + setArticles(prev => [...prev, ...(data.items ?? [])]) + setHasMore(data.has_more ?? false) + offsetRef.current += PAGE_SIZE + } catch {} + finally { + setLoadingMore(false) + busyRef.current = false + } + }, [hasMore, buildParams]) + + // IntersectionObserver на sentinel-div внизу списка + useEffect(() => { + const el = sentinelRef.current + if (!el) return + const observer = new IntersectionObserver( + entries => { if (entries[0].isIntersecting) loadMore() }, + { rootMargin: '300px' } + ) + observer.observe(el) + return () => observer.disconnect() + }, [loadMore]) const openArticle = async (slug) => { setModalArticle({ _loading: true }) @@ -92,8 +134,6 @@ export default function NewsFeed() { } } - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) - return ( <>
@@ -104,7 +144,6 @@ export default function NewsFeed() {
- {/* Строка поиска */}
@@ -129,10 +168,9 @@ export default function NewsFeed() { categories={categories} tags={tags} filters={filters} - onChange={setFilters} + onChange={f => setFilters(f)} /> - {/* Счётчик результатов при поиске */} {searchQuery && !loading && (

{total > 0 @@ -142,48 +180,47 @@ export default function NewsFeed() {

)} - {loading - ? - : articles.length === 0 - ?

- Новостей не найдено -

- :
- {articles.map(a => ( - openArticle(a.slug)} /> - ))} -
- } - - {!loading && totalPages > 1 && ( -
- - {filters.page} / {totalPages} - + {loading ? ( + + ) : articles.length === 0 ? ( +

+ Новостей не найдено +

+ ) : ( +
+ {articles.map(a => ( + openArticle(a.slug)} /> + ))}
)} + + {/* Sentinel для IntersectionObserver */} +
+ + {loadingMore && ( +
+ +
+ )} + + {!loading && !hasMore && articles.length > 0 && ( +

+ Все новости загружены · {total} +

+ )}
- {/* Модальное окно статьи */} {modalArticle && ( - modalArticle._loading - ? ( -
setModalArticle(null)}> -
e.stopPropagation()}> - -
+ modalArticle._loading ? ( +
setModalArticle(null)}> +
e.stopPropagation()}> +
- ) - : setModalArticle(null)} /> +
+ ) : ( + setModalArticle(null)} /> + ) )} ) diff --git a/web-react/src/pages/admin/SystemStatus.jsx b/web-react/src/pages/admin/SystemStatus.jsx new file mode 100644 index 0000000..069f98d --- /dev/null +++ b/web-react/src/pages/admin/SystemStatus.jsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useAuth } from '../../hooks/useAuth' +import { adminSystemStatus } from '../../api/index' +import AdminLayout from '../../components/AdminLayout' +import Loader from '../../components/Loader' + +function StatusBadge({ ok }) { + return ( + + {ok ? '●' : '●'} + {ok ? 'OK' : 'Ошибка'} + + ) +} + +function ServiceCard({ title, icon, data }) { + if (!data) return null + const rows = Object.entries(data).filter(([k]) => !['ok', 'error'].includes(k)) + + return ( +
+
+
+ {icon} + {title} +
+ +
+ + {data.error && ( +
+ {data.error} +
+ )} + + + + {rows.map(([key, value]) => ( + + + + + ))} + +
+ {LABELS[key] ?? key} + + {value === null ? + : value === true ? '✅ да' + : value === false ? '❌ нет' + : String(value)} +
+
+ ) +} + +const LABELS = { + host: 'Хост', + port: 'Порт', + database: 'База данных', + user: 'Пользователь', + version: 'Версия', + latency_ms: 'Задержка (мс)', + db: 'Номер БД', + endpoint: 'Эндпоинт', + bucket: 'Бакет', + public_url: 'Публичный URL', + bucket_exists: 'Бакет существует', +} + +export default function SystemStatus() { + const { token } = useAuth() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [lastUpdated, setLastUpdated] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + try { + const result = await adminSystemStatus(token) + setData(result) + setLastUpdated(new Date()) + } catch (e) { + console.error(e) + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { load() }, [load]) + + const allOk = data && Object.values(data).every(s => s.ok) + + return ( + +
+
+ {data && ( + + {allOk ? '✅ Все сервисы работают' : '⚠️ Есть проблемы'} + + )} + {lastUpdated && ( + + Обновлено: {lastUpdated.toLocaleTimeString()} + + )} +
+ +
+ + {loading && !data ? : ( +
+ + + +
+ )} +
+ ) +} diff --git a/web-react/src/pages/admin/VkSources.jsx b/web-react/src/pages/admin/VkSources.jsx index f44b575..d4ba3ba 100644 --- a/web-react/src/pages/admin/VkSources.jsx +++ b/web-react/src/pages/admin/VkSources.jsx @@ -17,8 +17,9 @@ export default function VkSources() { const [confirm, setConfirm] = useState(null) const [alert, setAlert] = useState(null) const [importing, setImporting] = useState('') + const [sinceDays, setSinceDays] = useState(365) - const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true }) + const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true, strict_filter: true }) const [editing, setEditing] = useState(null) const load = async () => { @@ -40,8 +41,8 @@ export default function VkSources() { const doAdd = async e => { e.preventDefault() try { - await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled) - setForm({ group_id: '', group_name: '', enabled: true }) + await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled, form.strict_filter) + setForm({ group_id: '', group_name: '', enabled: true, strict_filter: true }) flash('success', 'Источник добавлен') load() } catch(err) { flash('error', String(err.message)) } @@ -50,7 +51,7 @@ export default function VkSources() { const doUpdate = async () => { if (!editing) return try { - await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled) + await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled, editing.strict_filter) setEditing(null) flash('success', 'Сохранено') load() @@ -79,7 +80,7 @@ export default function VkSources() { const doHistory = async (sourceId = null) => { setImporting('history') try { - const r = await adminVkImportHistory(token, sourceId) + const r = await adminVkImportHistory(token, sourceId, sinceDays) flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`) } catch { flash('error', 'Ошибка загрузки истории') } finally { setImporting('') } @@ -103,7 +104,17 @@ export default function VkSources() { {status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'} -
+
+
@@ -148,6 +164,7 @@ export default function VkSources() { ID группы Название Активен + Фильтр тегов Действия @@ -178,7 +195,7 @@ export default function VkSources() { className="toggle-btn" data-on={s.enabled} title={s.enabled ? 'Отключить' : 'Включить'} - onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled) + onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled, s.strict_filter) .then(load).catch(() => flash('error', 'Ошибка'))} > {s.enabled ? '✅' : '❌'} @@ -186,6 +203,23 @@ export default function VkSources() { ) } + + {editing?.id === s.id + ? setEditing(ed => ({ ...ed, strict_filter: e.target.checked }))} /> + : ( + + ) + } +
{editing?.id === s.id @@ -208,7 +242,7 @@ export default function VkSources() { ))} {sources.length === 0 && ( - + Источников нет )} diff --git a/web/editor-ui/src/App.jsx b/web/editor-ui/src/App.jsx index 8a2d58b..b5c3893 100644 --- a/web/editor-ui/src/App.jsx +++ b/web/editor-ui/src/App.jsx @@ -73,6 +73,9 @@ export default function App() { const articleLoadedRef = useRef(false) const doSaveRef = useRef(null) // always points to latest doSave + // Loaded article data — fetched independently of editor lifecycle + const [articleData, setArticleData] = useState(null) + /* ── Editor ── */ const editor = useEditor({ extensions: [ @@ -84,7 +87,14 @@ export default function App() { Color, Highlight.configure({ multicolor: true }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }), - Image.configure({ allowBase64: false }), + Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + style: { default: null }, + } + }, + }).configure({ allowBase64: false }), Video, Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }), Table.configure({ resizable: true }), @@ -105,45 +115,51 @@ export default function App() { // Keep editorRef current every render so doSave never uses a stale instance editorRef.current = editor - /* ── Load categories once on mount ── */ + /* ── Load categories + article data on mount (independent of editor) ── */ useEffect(() => { api.getCategories(TOKEN).then(setCategories).catch(() => {}) + if (!IS_NEW) { + api.getArticle(ARTICLE_ID, TOKEN).then(setArticleData).catch(() => {}) + } }, []) - /* ── Load article once the editor is ready ── */ + /* ── Apply article data once BOTH editor AND data are ready ── */ useEffect(() => { - if (!editor || IS_NEW || articleLoadedRef.current) return + if (!editor || !articleData || articleLoadedRef.current) return articleLoadedRef.current = true - api.getArticle(ARTICLE_ID, TOKEN) - .then(data => { - setMeta({ - id: data.id, - title: data.title, - slug: data.slug, - excerpt: data.excerpt || '', - cover_url: data.cover_url || null, - source_url: data.source_url || null, - font_family: data.font_family || 'Merriweather', - category_id: data.category?.id || null, - tag_names: (data.tags || []).map(t => t.name), - status: data.status, - }) - const html = data.content?.startsWith('<') - ? data.content - : marked.parse(data.content || '') - const isFullDoc = /^\s* {}) - }, [editor]) + setMeta({ + id: articleData.id, + title: articleData.title, + slug: articleData.slug, + excerpt: articleData.excerpt || '', + cover_url: articleData.cover_url || null, + source_url: articleData.source_url || null, + font_family: articleData.font_family || 'Merriweather', + category_id: articleData.category?.id || null, + tag_names: (articleData.tags || []).map(t => t.name), + status: articleData.status, + }) + + const html = articleData.content?.startsWith('<') + ? articleData.content + : marked.parse(articleData.content || '') + const isFullDoc = /^\s* { + const ed = editorRef.current + if (!ed) return + if (isFullDoc) { + setRawHtml(html) + ed.commands.setContent('', false) + } else { + setRawHtml(null) + ed.commands.setContent(html, false) + } + setSaveStatus('saved') + }, 0) + }, [editor, articleData]) /* ── Save ───────────────────────────────────────────────────────────────── Uses editorRef + metaRef so this callback is stable (no deps that change).