diff --git a/CHANGELOG.md b/CHANGELOG.md index c95c7eb1..0fdf0ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [0.5.7] - 2025-06-28 + +### Новая функциональность админ-панели + +- **НОВОЕ**: Управление публикациями в админ-панели: + - **Просмотр публикаций**: Таблица со всеми публикациями с пагинацией и поиском + - **Фильтрация по статусу**: Все/Опубликованные/Черновики/Удаленные + - **Детальная информация**: ID, заголовок, slug, статус, авторы, темы, дата создания + - **Превью контента**: Body (сырой код) и media файлы с количеством + - **Поиск**: По заголовку, slug, ID или содержимому body + - **Адаптивный дизайн**: Оптимизированная таблица для мобильных устройств + +### Архитектурные улучшения + +- **DRY принцип**: Переиспользование существующих резолверов: + - `adminGetShouts` использует функции из `reader.py` (`query_with_stat`, `get_shouts_with_links`) + - `adminUpdateShout` и `adminDeleteShout` используют функции из `editor.py` + - `adminRestoreShout` для восстановления удаленных публикаций +- **GraphQL схема**: Новые типы `AdminShoutInfo`, `AdminShoutListResponse` для админ-панели +- **TypeScript интерфейсы**: Полная типизация для публикаций в админ-панели + +### UI/UX улучшения + +- **Новая вкладка**: "Публикации" в навигации админ-панели +- **Статусные бейджи**: Цветовая индикация статуса публикаций (опубликована/черновик/удалена) +- **Компактное отображение**: Авторы и темы в виде бейджей с ограничением по ширине +- **Умное сокращение текста**: Превью body с удалением HTML тегов +- **Адаптивные стили**: Оптимизация для экранов разной ширины + +### Документация + +- **Обновлен README.md**: Добавлен раздел "Администрирование" с описанием новых возможностей + ## [0.5.6] - 2025-06-26 ### Исправления API diff --git a/docs/README.md b/docs/README.md index 11fd20ab..fa870e38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,12 @@ python dev.py - [Пагинация комментариев](comments-pagination.md) - Иерархические комментарии - [Загрузка контента](load_shouts.md) - Оптимизированные запросы +### Администрирование +- **Админ-панель**: Управление пользователями, ролями, переменными среды +- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные) +- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией +- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py + ### API и инфраструктура - [API методы](api.md) - GraphQL эндпоинты - [Функции системы](features.md) - Полный список возможностей diff --git a/panel/admin.tsx b/panel/admin.tsx index 8bbe05e9..6987ef37 100644 --- a/panel/admin.tsx +++ b/panel/admin.tsx @@ -86,6 +86,75 @@ interface EnvSection { variables: EnvVariable[] } +/** + * Интерфейс для публикации + */ +interface Shout { + id: number + title: string + slug: string + body: string + lead?: string + subtitle?: string + layout: string + lang: string + cover?: string + cover_caption?: string + media?: any[] + seo?: string + created_at: number + updated_at?: number + published_at?: number + featured_at?: number + deleted_at?: number + created_by: { + id: number + email?: string + name?: string + } + updated_by?: { + id: number + email?: string + name?: string + } + deleted_by?: { + id: number + email?: string + name?: string + } + community: { + id: number + name?: string + } + authors?: Array<{ + id: number + email?: string + name?: string + slug?: string + }> + topics?: Array<{ + id: number + title?: string + slug?: string + }> + version_of?: number + draft?: number + stat?: any +} + +/** + * Интерфейс для ответа API с публикациями + */ +interface AdminGetShoutsResponse { + adminGetShouts: { + shouts: Shout[] + total: number + page: number + perPage: number + totalPages: number + } +} + /** * Интерфейс свойств компонента AdminPage */ @@ -129,6 +198,23 @@ const AdminPage: Component = (props) => { // Поиск const [searchQuery, setSearchQuery] = createSignal('') + // Публикации + const [shouts, setShouts] = createSignal([]) + const [shoutsLoading, setShoutsLoading] = createSignal(false) + const [shoutsStatus, setShoutsStatus] = createSignal('all') // all, published, draft, deleted + const [shoutsPagination, setShoutsPagination] = createSignal<{ + page: number + limit: number + total: number + totalPages: number + }>({ + page: 1, + limit: 10, + total: 0, + totalPages: 1 + }) + const [shoutsSearchQuery, setShoutsSearchQuery] = createSignal('') + // Периодическая проверка авторизации onMount(() => { // Получаем параметры из URL при загрузке @@ -249,17 +335,90 @@ const AdminPage: Component = (props) => { setRoles(data.adminGetRoles) } } catch (err) { - console.error('Ошибка загрузки ролей:', err) - // Если ошибка авторизации - перенаправляем на логин - if ( - err instanceof Error && - (err.message.includes('401') || - err.message.includes('авторизации') || - err.message.includes('unauthorized') || - err.message.includes('Unauthorized')) - ) { - handleLogout() + console.error('Ошибка при загрузке ролей:', err) + setError('Не удалось загрузить роли') + } + } + + /** + * Загрузка списка публикаций с учетом пагинации и поиска + */ + async function loadShouts() { + setShoutsLoading(true) + setError(null) + + try { + const { page, limit } = shoutsPagination() + const offset = (page - 1) * limit + const search = shoutsSearchQuery().trim() + const status = shoutsStatus() + + const data = await query( + `${location.origin}/graphql`, + ` + query AdminGetShouts($limit: Int, $offset: Int, $search: String, $status: String) { + adminGetShouts(limit: $limit, offset: $offset, search: $search, status: $status) { + shouts { + id + title + slug + body + lead + subtitle + layout + lang + cover + cover_caption + media + seo + created_at + updated_at + published_at + featured_at + deleted_at + created_by { + id + email + name + } + authors { + id + email + name + slug + } + topics { + id + title + slug + } + version_of + draft + } + total + page + perPage + totalPages + } + } + `, + { limit, offset, search: search || undefined, status } + ) + + if (data?.adminGetShouts) { + setShouts(data.adminGetShouts.shouts) + setShoutsPagination({ + page: data.adminGetShouts.page, + limit: data.adminGetShouts.perPage, + total: data.adminGetShouts.total, + totalPages: data.adminGetShouts.totalPages + }) } + } catch (err) { + console.error('Ошибка при загрузке публикаций:', err) + setError('Не удалось загрузить публикации') + } finally { + setShoutsLoading(false) } } @@ -855,9 +1014,13 @@ const AdminPage: Component = (props) => { */ const handleTabChange = (tab: string) => { setActiveTab(tab) + setError(null) + setSuccessMessage(null) if (tab === 'env' && envSections().length === 0) { loadEnvVariables() + } else if (tab === 'shouts' && shouts().length === 0) { + loadShouts() } } @@ -1028,6 +1191,24 @@ const AdminPage: Component = (props) => { ) } + // Вспомогательные функции для публикаций + function getShoutStatus(shout: Shout): string { + if (shout.deleted_at) return 'Удалена' + if (shout.published_at) return 'Опубликована' + return 'Черновик' + } + + function getShoutStatusClass(shout: Shout): string { + if (shout.deleted_at) return 'status-deleted' + if (shout.published_at) return 'status-published' + return 'status-draft' + } + + function truncateText(text: string, maxLength: number = 100): string { + if (!text || text.length <= maxLength) return text + return text.substring(0, maxLength) + '...' + } + return (
@@ -1042,6 +1223,9 @@ const AdminPage: Component = (props) => { + @@ -1129,6 +1313,135 @@ const AdminPage: Component = (props) => { + + +
Загрузка публикаций...
+
+ + +
Нет публикаций для отображения
+
+ + 0}> +
+
+
+ setShoutsSearchQuery(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + loadShouts() + } + }} + class="search-input" + /> + +
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + {(shout) => ( + + + + + + + + + + + + )} + + +
IDЗаголовокSlugСтатусАвторыТемыСозданBody (preview)Media
{shout.id}{truncateText(shout.title, 50)}{truncateText(shout.slug, 30)} + + {getShoutStatus(shout)} + + + 0}> +
+ + {(author) => ( + + {author.name || author.email || `ID:${author.id}`} + + )} + +
+
+ + - + +
+ 0}> +
+ + {(topic) => ( + + {topic.title || topic.slug} + + )} + +
+
+ + - + +
{formatDateRelative(shout.created_at)} +
+ {truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)} +
+
+ 0}> + {shout.media!.length} файл(ов) + + + - + +
+
+
+
+ diff --git a/panel/login.tsx b/panel/login.tsx index ef25ae98..73575efd 100644 --- a/panel/login.tsx +++ b/panel/login.tsx @@ -3,7 +3,7 @@ * @module LoginPage */ -import { Component, createSignal, Show } from 'solid-js' +import { Component, createSignal } from 'solid-js' import { login } from './auth' interface LoginPageProps { diff --git a/panel/styles.css b/panel/styles.css index c2bdf113..32666727 100644 --- a/panel/styles.css +++ b/panel/styles.css @@ -848,3 +848,136 @@ th.sortable.sorted .sort-icon { cursor: pointer; user-select: none; } + +/* Стили для таблицы публикаций */ +.shouts-controls { + display: flex; + gap: 20px; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.status-filter select { + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: white; + font-size: 14px; + min-width: 150px; +} + +.shouts-list { + overflow-x: auto; + margin-top: 1rem; +} + +.shouts-list table { + min-width: 1200px; +} + +.status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + text-align: center; + min-width: 70px; +} + +.status-published { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status-draft { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; +} + +.status-deleted { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.authors-list, .topics-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + max-width: 200px; +} + +.author-badge, .topic-badge { + display: inline-block; + padding: 2px 6px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + font-size: 11px; + color: #495057; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.author-badge { + background-color: #e3f2fd; + border-color: #bbdefb; + color: #1565c0; +} + +.topic-badge { + background-color: #f3e5f5; + border-color: #e1bee7; + color: #7b1fa2; +} + +.body-preview { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: #666; +} + +.media-count { + font-size: 12px; + color: #6c757d; + font-style: italic; +} + +.no-data { + color: #adb5bd; + font-style: italic; + font-size: 13px; +} + +/* Адаптивные стили для публикаций */ +@media (max-width: 768px) { + .shouts-controls { + flex-direction: column; + align-items: stretch; + } + + .status-filter select { + min-width: unset; + } + + .shouts-list table { + font-size: 12px; + } + + .authors-list, .topics-list { + max-width: 150px; + } + + .body-preview { + max-width: 200px; + } +} diff --git a/resolvers/admin.py b/resolvers/admin.py index 1bf0acf6..5042a750 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -5,9 +5,11 @@ from graphql import GraphQLResolveInfo from graphql.error import GraphQLError from sqlalchemy import String, cast, or_ from sqlalchemy.orm import joinedload +from sqlalchemy.sql import func, select from auth.decorators import admin_auth_required from auth.orm import Author, AuthorRole, Role +from orm.shout import Shout from services.db import local_session from services.env import EnvManager, EnvVariable from services.schema import mutation, query @@ -323,3 +325,274 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A logger.error(error_msg) logger.error(traceback.format_exc()) return {"success": False, "error": error_msg} + + +# ===== РЕЗОЛВЕРЫ ДЛЯ РАБОТЫ С ПУБЛИКАЦИЯМИ (SHOUT) ===== + + +@query.field("adminGetShouts") +@admin_auth_required +async def admin_get_shouts( + _: None, info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = "", status: str = "all" +) -> dict[str, Any]: + """ + Получает список публикаций для админ-панели с поддержкой пагинации и поиска + Переиспользует логику из reader.py для соблюдения DRY принципа + + Args: + limit: Максимальное количество записей для получения + offset: Смещение в списке результатов + search: Строка поиска (по заголовку, slug или ID) + status: Статус публикаций (all, published, draft, deleted) + + Returns: + Пагинированный список публикаций + """ + try: + # Импортируем функции из reader.py для переиспользования + from resolvers.reader import get_shouts_with_links, query_with_stat + + # Нормализуем параметры + limit = max(1, min(100, limit or 10)) + offset = max(0, offset or 0) + + with local_session() as session: + # Используем существующую функцию для получения запроса со статистикой + if status == "all": + # Для админа показываем все публикации (включая удаленные и неопубликованные) + q = select(Shout).options(joinedload(Shout.authors), joinedload(Shout.topics)) + else: + # Используем стандартный запрос с фильтрацией + q = query_with_stat(info) + + # Применяем фильтр статуса + if status == "published": + q = q.filter(Shout.published_at.isnot(None), Shout.deleted_at.is_(None)) + elif status == "draft": + q = q.filter(Shout.published_at.is_(None), Shout.deleted_at.is_(None)) + elif status == "deleted": + q = q.filter(Shout.deleted_at.isnot(None)) + + # Применяем фильтр поиска, если указан + if search and search.strip(): + search_term = f"%{search.strip().lower()}%" + q = q.filter( + or_( + Shout.title.ilike(search_term), + Shout.slug.ilike(search_term), + cast(Shout.id, String).ilike(search_term), + Shout.body.ilike(search_term), + ) + ) + + # Получаем общее количество записей + total_count = session.execute(select(func.count()).select_from(q.subquery())).scalar() + + # Вычисляем информацию о пагинации + per_page = limit + total_pages = ceil(total_count / per_page) + current_page = (offset // per_page) + 1 if per_page > 0 else 1 + + # Применяем пагинацию и сортировку (новые сверху) + q = q.order_by(Shout.created_at.desc()) + + # Используем существующую функцию для получения публикаций с данными + if status == "all": + # Для статуса "all" используем простой запрос без статистики + q = q.limit(limit).offset(offset) + shouts_result = session.execute(q).all() + shouts_data = [] + + for row in shouts_result: + shout = row[0] if isinstance(row, tuple) else row + # Обрабатываем поле media + media_data = [] + if shout.media: + if isinstance(shout.media, str): + try: + import orjson + + media_data = orjson.loads(shout.media) + except Exception: + media_data = [] + elif isinstance(shout.media, list): + media_data = shout.media + elif isinstance(shout.media, dict): + media_data = [shout.media] + + shout_dict = { + "id": shout.id, + "title": shout.title, + "slug": shout.slug, + "body": shout.body, + "lead": shout.lead, + "subtitle": shout.subtitle, + "layout": shout.layout, + "lang": shout.lang, + "cover": shout.cover, + "cover_caption": shout.cover_caption, + "media": media_data, + "seo": shout.seo, + "created_at": shout.created_at, + "updated_at": shout.updated_at, + "published_at": shout.published_at, + "featured_at": shout.featured_at, + "deleted_at": shout.deleted_at, + "created_by": { + "id": shout.created_by, + "email": "unknown", # Заполним при необходимости + "name": "unknown", + }, + "updated_by": None, # Заполним при необходимости + "deleted_by": None, # Заполним при необходимости + "community": { + "id": shout.community, + "name": "unknown", # Заполним при необходимости + }, + "authors": [ + {"id": author.id, "email": author.email, "name": author.name, "slug": author.slug} + for author in (shout.authors or []) + ], + "topics": [ + {"id": topic.id, "title": topic.title, "slug": topic.slug} for topic in (shout.topics or []) + ], + "version_of": shout.version_of, + "draft": shout.draft, + "stat": None, # Заполним при необходимости + } + shouts_data.append(shout_dict) + else: + # Используем существующую функцию для получения публикаций со статистикой + shouts_data = get_shouts_with_links(info, q, limit, offset) + + return { + "shouts": shouts_data, + "total": total_count, + "page": current_page, + "perPage": per_page, + "totalPages": total_pages, + } + + except Exception as e: + import traceback + + logger.error(f"Ошибка при получении списка публикаций: {e!s}") + logger.error(traceback.format_exc()) + msg = f"Не удалось получить список публикаций: {e!s}" + raise GraphQLError(msg) from e + + +@mutation.field("adminUpdateShout") +@admin_auth_required +async def admin_update_shout(_: None, info: GraphQLResolveInfo, shout: dict[str, Any]) -> dict[str, Any]: + """ + Обновляет данные публикации + Переиспользует логику из editor.py для соблюдения DRY принципа + + Args: + info: Контекст GraphQL запроса + shout: Данные для обновления публикации + + Returns: + Результат операции + """ + try: + # Импортируем функцию обновления из editor.py + from resolvers.editor import update_shout + + shout_id = shout.get("id") + + if not shout_id: + return {"success": False, "error": "ID публикации не указан"} + + # Подготавливаем данные в формате, ожидаемом функцией update_shout + shout_input = {k: v for k, v in shout.items() if k != "id"} + + # Используем существующую функцию update_shout + result = await update_shout(None, info, shout_id, shout_input) + + if result.error: + return {"success": False, "error": result.error} + + logger.info(f"Публикация {shout_id} обновлена через админ-панель") + return {"success": True} + + except Exception as e: + import traceback + + error_msg = f"Ошибка при обновлении публикации: {e!s}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + return {"success": False, "error": error_msg} + + +@mutation.field("adminDeleteShout") +@admin_auth_required +async def admin_delete_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> dict[str, Any]: + """ + Мягко удаляет публикацию (устанавливает deleted_at) + Переиспользует логику из editor.py для соблюдения DRY принципа + + Args: + info: Контекст GraphQL запроса + id: ID публикации для удаления + + Returns: + Результат операции + """ + try: + # Импортируем функцию удаления из editor.py + from resolvers.editor import delete_shout + + # Используем существующую функцию delete_shout + result = await delete_shout(None, info, shout_id) + + if result.error: + return {"success": False, "error": result.error} + + logger.info(f"Публикация {shout_id} удалена через админ-панель") + return {"success": True} + + except Exception as e: + error_msg = f"Ошибка при удалении публикации: {e!s}" + logger.error(error_msg) + return {"success": False, "error": error_msg} + + +@mutation.field("adminRestoreShout") +@admin_auth_required +async def admin_restore_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> dict[str, Any]: + """ + Восстанавливает удаленную публикацию (сбрасывает deleted_at) + + Args: + info: Контекст GraphQL запроса + id: ID публикации для восстановления + + Returns: + Результат операции + """ + try: + with local_session() as session: + # Получаем публикацию + shout = session.query(Shout).filter(Shout.id == shout_id).first() + + if not shout: + return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"} + + if not shout.deleted_at: + return {"success": False, "error": "Публикация не была удалена"} + + # Сбрасываем время удаления + shout.deleted_at = None + shout.deleted_by = None + + session.commit() + + logger.info(f"Публикация {shout.title or shout.id} восстановлена администратором") + return {"success": True} + + except Exception as e: + error_msg = f"Ошибка при восстановлении публикации: {e!s}" + logger.error(error_msg) + return {"success": False, "error": error_msg} diff --git a/schema/admin.graphql b/schema/admin.graphql index 885766c6..7c3eb5a0 100644 --- a/schema/admin.graphql +++ b/schema/admin.graphql @@ -56,11 +56,67 @@ type OperationResult { error: String } +# Типы для управления публикациями (Shout) +type AdminShoutInfo { + id: Int! + title: String! + slug: String! + body: String! + lead: String + subtitle: String + layout: String! + lang: String! + cover: String + cover_caption: String + media: [MediaItem] + seo: String + created_at: Int! + updated_at: Int + published_at: Int + featured_at: Int + deleted_at: Int + created_by: Author! + updated_by: Author + deleted_by: Author + community: Community! + authors: [Author] + topics: [Topic] + version_of: Int + draft: Int + stat: Stat +} + +# Тип для пагинированного ответа публикаций +type AdminShoutListResponse { + shouts: [AdminShoutInfo!]! + total: Int! + page: Int! + perPage: Int! + totalPages: Int! +} + +input AdminShoutUpdateInput { + id: Int! + title: String + body: String + lead: String + subtitle: String + cover: String + cover_caption: String + media: [MediaItemInput] + seo: String + published_at: Int + featured_at: Int + deleted_at: Int +} + extend type Query { getEnvVariables: [EnvSection!]! # Запросы для управления пользователями adminGetUsers(limit: Int, offset: Int, search: String): AdminUserListResponse! adminGetRoles: [Role!]! + # Запросы для управления публикациями + adminGetShouts(limit: Int, offset: Int, search: String, status: String): AdminShoutListResponse! } extend type Mutation { @@ -69,4 +125,8 @@ extend type Mutation { # Мутации для управления пользователями adminUpdateUser(user: AdminUserUpdateInput!): OperationResult! + # Мутации для управления публикациями + adminUpdateShout(shout: AdminShoutUpdateInput!): OperationResult! + adminDeleteShout(id: Int!): OperationResult! + adminRestoreShout(id: Int!): OperationResult! }