infinity serch
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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) ─────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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')
|
||||
115
api/route/admin_system.py
Normal file
115
api/route/admin_system.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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}» запущен в фоне"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
61
docker-compose.external.yml
Normal file
61
docker-compose.external.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
|
||||
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
|
||||
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
|
||||
<Route path="/admin/system" element={<PrivateRoute><SystemStatus /></PrivateRoute>} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="filter-bar">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<header className="public-header">
|
||||
@@ -104,7 +144,6 @@ export default function NewsFeed() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Строка поиска */}
|
||||
<div className="search-bar-wrap">
|
||||
<div className="search-bar">
|
||||
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -129,10 +168,9 @@ export default function NewsFeed() {
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onChange={f => setFilters(f)}
|
||||
/>
|
||||
|
||||
{/* Счётчик результатов при поиске */}
|
||||
{searchQuery && !loading && (
|
||||
<p className="search-results-hint">
|
||||
{total > 0
|
||||
@@ -142,48 +180,47 @@ export default function NewsFeed() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <Loader />
|
||||
: articles.length === 0
|
||||
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||
Новостей не найдено
|
||||
</p>
|
||||
: <div className="news-list">
|
||||
{articles.map(a => (
|
||||
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
|
||||
>← Назад</button>
|
||||
<span className="pagination__info">{filters.page} / {totalPages}</span>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={filters.page >= totalPages}
|
||||
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
|
||||
>Вперёд →</button>
|
||||
{loading ? (
|
||||
<Loader />
|
||||
) : articles.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
|
||||
Новостей не найдено
|
||||
</p>
|
||||
) : (
|
||||
<div className="news-list">
|
||||
{articles.map(a => (
|
||||
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sentinel для IntersectionObserver */}
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
|
||||
{loadingMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '1.5rem 0' }}>
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasMore && articles.length > 0 && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', fontSize: '0.85rem', padding: '1.5rem 0' }}>
|
||||
Все новости загружены · {total}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Модальное окно статьи */}
|
||||
{modalArticle && (
|
||||
modalArticle._loading
|
||||
? (
|
||||
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<Loader />
|
||||
</div>
|
||||
modalArticle._loading ? (
|
||||
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
|
||||
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<Loader />
|
||||
</div>
|
||||
)
|
||||
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
144
web-react/src/pages/admin/SystemStatus.jsx
Normal file
144
web-react/src/pages/admin/SystemStatus.jsx
Normal file
@@ -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 (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 10px', borderRadius: 99, fontSize: '0.8rem', fontWeight: 600,
|
||||
background: ok ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
|
||||
color: ok ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.65rem' }}>{ok ? '●' : '●'}</span>
|
||||
{ok ? 'OK' : 'Ошибка'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceCard({ title, icon, data }) {
|
||||
if (!data) return null
|
||||
const rows = Object.entries(data).filter(([k]) => !['ok', 'error'].includes(k))
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--c-surface)',
|
||||
borderRadius: 'var(--r-card)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderLeft: `4px solid ${data.ok ? 'var(--c-success, #10b981)' : 'var(--c-danger, #ef4444)'}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '1.4rem' }}>{icon}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem' }}>{title}</span>
|
||||
</div>
|
||||
<StatusBadge ok={data.ok} />
|
||||
</div>
|
||||
|
||||
{data.error && (
|
||||
<div style={{
|
||||
background: 'var(--c-danger-bg, #fee2e2)',
|
||||
color: 'var(--c-danger, #991b1b)',
|
||||
borderRadius: 6, padding: '0.5rem 0.75rem',
|
||||
fontSize: '0.8rem', fontFamily: 'monospace',
|
||||
marginBottom: '0.75rem', wordBreak: 'break-all',
|
||||
}}>
|
||||
{data.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<tbody>
|
||||
{rows.map(([key, value]) => (
|
||||
<tr key={key} style={{ borderBottom: '1px solid var(--c-border, #e5e7eb)' }}>
|
||||
<td style={{ padding: '0.4rem 0', color: 'var(--c-text-2)', width: '40%', fontWeight: 500 }}>
|
||||
{LABELS[key] ?? key}
|
||||
</td>
|
||||
<td style={{ padding: '0.4rem 0', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{value === null ? <span style={{ color: 'var(--c-text-2)' }}>—</span>
|
||||
: value === true ? '✅ да'
|
||||
: value === false ? '❌ нет'
|
||||
: String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AdminLayout title="Состояние сервисов">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{data && (
|
||||
<span style={{
|
||||
padding: '4px 14px', borderRadius: 99, fontWeight: 700, fontSize: '0.85rem',
|
||||
background: allOk ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
|
||||
color: allOk ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
|
||||
}}>
|
||||
{allOk ? '✅ Все сервисы работают' : '⚠️ Есть проблемы'}
|
||||
</span>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--c-text-2)' }}>
|
||||
Обновлено: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={load} disabled={loading}>
|
||||
{loading ? 'Проверяю...' : '🔄 Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !data ? <Loader /> : (
|
||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
|
||||
<ServiceCard title="PostgreSQL" icon="🐘" data={data?.postgres} />
|
||||
<ServiceCard title="Redis" icon="⚡" data={data?.redis} />
|
||||
<ServiceCard title="MinIO" icon="🗄️" data={data?.minio} />
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
|
||||
</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem' }}>
|
||||
<span style={{ color: 'var(--c-text-2)' }}>Глубина:</span>
|
||||
<input
|
||||
type="number" min={1} max={3650} value={sinceDays}
|
||||
onChange={e => setSinceDays(Number(e.target.value))}
|
||||
className="form-input"
|
||||
style={{ width: 70, padding: '3px 6px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--c-text-2)' }}>дн.</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={doImport}
|
||||
@@ -136,6 +147,11 @@ export default function VkSources() {
|
||||
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||||
Активен
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
|
||||
<input type="checkbox" checked={form.strict_filter}
|
||||
onChange={e => setForm(f => ({ ...f, strict_filter: e.target.checked }))} />
|
||||
Строгий фильтр
|
||||
</label>
|
||||
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -148,6 +164,7 @@ export default function VkSources() {
|
||||
<th>ID группы</th>
|
||||
<th>Название</th>
|
||||
<th>Активен</th>
|
||||
<th title="Строгий фильтр: только посты с тегами. Выкл — все посты паблика">Фильтр тегов</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -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() {
|
||||
)
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{editing?.id === s.id
|
||||
? <input type="checkbox" checked={editing.strict_filter}
|
||||
onChange={e => setEditing(ed => ({ ...ed, strict_filter: e.target.checked }))} />
|
||||
: (
|
||||
<button
|
||||
className="toggle-btn"
|
||||
data-on={s.strict_filter}
|
||||
title={s.strict_filter ? 'Строгий фильтр: только посты с тегами. Нажмите чтобы отключить' : 'Фильтр выключен: импортируются все посты. Нажмите чтобы включить'}
|
||||
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, s.enabled, !s.strict_filter)
|
||||
.then(load).catch(() => flash('error', 'Ошибка'))}
|
||||
>
|
||||
{s.strict_filter ? '🔒' : '🔓'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div className="td-actions">
|
||||
{editing?.id === s.id
|
||||
@@ -208,7 +242,7 @@ export default function VkSources() {
|
||||
</tr>
|
||||
))}
|
||||
{sources.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
|
||||
Источников нет
|
||||
</td></tr>
|
||||
)}
|
||||
|
||||
@@ -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*<!DOCTYPE|^\s*<html/i.test(html)
|
||||
if (isFullDoc) {
|
||||
setRawHtml(html)
|
||||
editor.commands.setContent('', false)
|
||||
} else {
|
||||
setRawHtml(null)
|
||||
editor.commands.setContent(html, false)
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [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*<!DOCTYPE|^\s*<html/i.test(html)
|
||||
|
||||
// Use setTimeout to ensure React has committed meta state before setContent
|
||||
setTimeout(() => {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user