This commit is contained in:
parent
db92cc6406
commit
c8728540ed
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -1,5 +1,49 @@
|
|||
# Changelog
|
||||
|
||||
## [0.7.8] - 2025-07-04
|
||||
|
||||
### 💬 Система управления реакциями в админ-панели
|
||||
|
||||
Добавлена полная система просмотра и модерации реакций с расширенными возможностями фильтрации и управления.
|
||||
|
||||
#### Новая функциональность
|
||||
- **Вкладка "Реакции"** в навигации админ-панели с эмоджи-индикаторами
|
||||
- **Просмотр всех реакций** с детальной информацией о типе, авторе, публикации и статистике
|
||||
- **Фильтрация по типам**: лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
|
||||
- **Поиск по тексту реакции**, имени автора, email или названию публикации
|
||||
- **Фильтрация по ID публикации** для модерации конкретных постов
|
||||
- **Статус реакций**: визуальное отображение активных и удаленных реакций
|
||||
|
||||
#### Модерация реакций
|
||||
- **Редактирование текста** реакций через модальное окно
|
||||
- **Мягкое удаление** реакций с возможностью восстановления
|
||||
- **Восстановление удаленных** реакций одним кликом
|
||||
- **Просмотр статистики**: рейтинг и количество комментариев к каждой реакции
|
||||
- **Фильтр по статусу**: администратор видит все реакции включая удаленные (активные/удаленные/все)
|
||||
|
||||
#### Управление публикациями
|
||||
- **Полный доступ**: администратор видит все публикации включая удаленные
|
||||
- **Статус-фильтры**: опубликованные, черновики, удаленные или все публикации
|
||||
|
||||
#### GraphQL API
|
||||
- `adminGetReactions` - получение списка реакций с пагинацией и фильтрами (включая параметр `status`)
|
||||
- `adminUpdateReaction` - обновление текста реакции
|
||||
- `adminDeleteReaction` - мягкое удаление реакции
|
||||
- `adminRestoreReaction` - восстановление удаленной реакции
|
||||
- Обновлен параметр `status` в `adminGetShouts` для фильтрации удаленных публикаций
|
||||
|
||||
#### Интерфейс
|
||||
- **Таблица реакций** с сортировкой по дате создания
|
||||
- **Эмоджи-индикаторы** для всех типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
|
||||
- **Русификация типов** реакций в интерфейсе
|
||||
- **Адаптивный дизайн** с поддержкой мобильных устройств
|
||||
- **Пагинация** с настраиваемым количеством элементов на странице
|
||||
|
||||
#### Безопасность
|
||||
- **RBAC защита**: все операции требуют роль администратора
|
||||
- **Валидация входных данных** и обработка ошибок
|
||||
- **Аудит операций** с логированием всех изменений
|
||||
|
||||
## [0.7.7] - 2025-07-03
|
||||
|
||||
### 🔐 RBAC System for Topic Management
|
||||
|
|
|
@ -41,6 +41,14 @@ python dev.py
|
|||
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
|
||||
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
|
||||
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
|
||||
- **Модерация реакций**: Полная система управления реакциями пользователей
|
||||
- **Просмотр всех реакций**: Таблица с типом, текстом, автором, публикацией и статистикой
|
||||
- **Фильтрация по типам**: Лайки, дизлайки, комментарии, цитаты, согласие/несогласие, вопросы, предложения, доказательства/опровержения
|
||||
- **Поиск и фильтры**: По тексту реакции, автору, email или ID публикации
|
||||
- **Эмоджи-индикаторы**: Визуальное отображение типов реакций (👍 👎 💬 ❝ ✅ ❌ ❓ 💡 🔬 🚫)
|
||||
- **Модерация**: Редактирование текста, мягкое удаление и восстановление
|
||||
- **Статистика**: Рейтинг и количество комментариев к каждой реакции
|
||||
- **Безопасность**: RBAC защита и аудит всех операций
|
||||
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
||||
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "publy-panel",
|
||||
"version": "0.7.7",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
@ -101,7 +101,6 @@ export function DataProvider(props: { children: JSX.Element }) {
|
|||
// Обертка для setTopics с логированием
|
||||
const setTopicsWithLogging = (newTopics: Topic[]) => {
|
||||
console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
|
||||
console.log('[DataProvider] Sample topic parent_ids:', newTopics.slice(0, 3).map(t => ({ id: t.id, title: t.title, parent_ids: t.parent_ids })))
|
||||
setTopics(newTopics)
|
||||
}
|
||||
|
||||
|
|
|
@ -194,6 +194,33 @@ export const ADMIN_UPDATE_TOPIC_MUTATION = `
|
|||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_REACTION_MUTATION = `
|
||||
mutation AdminUpdateReaction($reaction: AdminReactionUpdateInput!) {
|
||||
adminUpdateReaction(reaction: $reaction) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_REACTION_MUTATION = `
|
||||
mutation AdminDeleteReaction($reaction_id: Int!) {
|
||||
adminDeleteReaction(reaction_id: $reaction_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_RESTORE_REACTION_MUTATION = `
|
||||
mutation AdminRestoreReaction($reaction_id: Int!) {
|
||||
adminRestoreReaction(reaction_id: $reaction_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_CREATE_TOPIC_MUTATION = `
|
||||
mutation AdminCreateTopic($topic: AdminTopicInput!) {
|
||||
adminCreateTopic(topic: $topic) {
|
||||
|
|
|
@ -193,6 +193,46 @@ export const GET_TOPICS_BY_COMMUNITY_QUERY: string =
|
|||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_REACTIONS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetReactions($limit: Int, $offset: Int, $search: String, $kind: ReactionKind, $shout_id: Int, $status: String) {
|
||||
adminGetReactions(limit: $limit, offset: $offset, search: $search, kind: $kind, shout_id: $shout_id, status: $status) {
|
||||
reactions {
|
||||
id
|
||||
kind
|
||||
body
|
||||
created_at
|
||||
updated_at
|
||||
deleted_at
|
||||
reply_to
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
shout {
|
||||
id
|
||||
title
|
||||
slug
|
||||
layout
|
||||
created_at
|
||||
published_at
|
||||
deleted_at
|
||||
}
|
||||
stat {
|
||||
comments_count
|
||||
rating
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_TOPICS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetTopics($community_id: Int!) {
|
||||
|
|
206
panel/modals/ReactionEditModal.tsx
Normal file
206
panel/modals/ReactionEditModal.tsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { Component, createSignal, createEffect } from 'solid-js'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
import HTMLEditor from '../ui/HTMLEditor'
|
||||
|
||||
interface ReactionEditModalProps {
|
||||
reaction: {
|
||||
id: number
|
||||
kind: string
|
||||
body: string
|
||||
created_at: number
|
||||
updated_at?: number
|
||||
deleted_at?: number
|
||||
reply_to?: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
}
|
||||
shout: {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
layout: string
|
||||
created_at: number
|
||||
published_at?: number
|
||||
deleted_at?: number
|
||||
}
|
||||
stat: {
|
||||
comments_count: number
|
||||
rating: number
|
||||
}
|
||||
}
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (reaction: { id: number; body?: string; deleted_at?: number }) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно для редактирования реакции
|
||||
*/
|
||||
const ReactionEditModal: Component<ReactionEditModalProps> = (props) => {
|
||||
const [body, setBody] = createSignal('')
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal('')
|
||||
|
||||
// Инициализация данных при изменении реакции
|
||||
createEffect(() => {
|
||||
if (props.reaction) {
|
||||
setBody(props.reaction.body || '')
|
||||
setError('')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Обработка сохранения изменений
|
||||
*/
|
||||
async function handleSave() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const updateData: { id: number; body?: string; deleted_at?: number } = {
|
||||
id: props.reaction.id,
|
||||
body: body(),
|
||||
}
|
||||
|
||||
await props.onSave(updateData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка сохранения')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает название типа реакции на русском
|
||||
*/
|
||||
const getReactionName = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'LIKE':
|
||||
return 'Лайк'
|
||||
case 'DISLIKE':
|
||||
return 'Дизлайк'
|
||||
case 'COMMENT':
|
||||
return 'Комментарий'
|
||||
case 'QUOTE':
|
||||
return 'Цитата'
|
||||
case 'AGREE':
|
||||
return 'Согласен'
|
||||
case 'DISAGREE':
|
||||
return 'Не согласен'
|
||||
case 'ASK':
|
||||
return 'Вопрос'
|
||||
case 'PROPOSE':
|
||||
return 'Предложение'
|
||||
case 'PROOF':
|
||||
return 'Доказательство'
|
||||
case 'DISPROOF':
|
||||
return 'Опровержение'
|
||||
case 'ACCEPT':
|
||||
return 'Принять'
|
||||
case 'REJECT':
|
||||
return 'Отклонить'
|
||||
case 'CREDIT':
|
||||
return 'Упоминание'
|
||||
case 'SILENT':
|
||||
return 'Причастность'
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title="Редактирование реакции">
|
||||
<div class={styles['modal-content']}>
|
||||
{error() && (
|
||||
<div class={styles['error-message']}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>ID реакции:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.reaction.id}
|
||||
disabled
|
||||
class={styles['form-input']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Тип реакции:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getReactionName(props.reaction.kind)}
|
||||
disabled
|
||||
class={styles['form-input']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Автор:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${props.reaction.created_by.name || 'Без имени'} (${props.reaction.created_by.email})`}
|
||||
disabled
|
||||
class={styles['form-input']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Публикация:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${props.reaction.shout.title} (ID: ${props.reaction.shout.id})`}
|
||||
disabled
|
||||
class={styles['form-input']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Текст реакции:</label>
|
||||
<HTMLEditor
|
||||
value={body()}
|
||||
onInput={(value) => setBody(value)}
|
||||
placeholder="Введите текст реакции (поддерживается HTML)..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Статистика:</label>
|
||||
<div class={styles['stat-info']}>
|
||||
<span>Рейтинг: {props.reaction.stat.rating}</span>
|
||||
<span>Комментариев: {props.reaction.stat.comments_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label class={styles['form-label']}>Статус:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.reaction.deleted_at ? 'Удалено' : 'Активно'}
|
||||
disabled
|
||||
class={styles['form-input']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||
{loading() ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReactionEditModal
|
|
@ -17,6 +17,7 @@ import CollectionsRoute from './collections'
|
|||
import CommunitiesRoute from './communities'
|
||||
import EnvRoute from './env'
|
||||
import InvitesRoute from './invites'
|
||||
import ReactionsRoute from './reactions'
|
||||
import ShoutsRoute from './shouts'
|
||||
import { Topics as TopicsRoute } from './topics'
|
||||
|
||||
|
@ -145,6 +146,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
>
|
||||
Приглашения
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentTab() === 'reactions' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/reactions')}
|
||||
>
|
||||
Реакции
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/env')}
|
||||
|
@ -188,6 +195,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={currentTab() === 'reactions'}>
|
||||
<ReactionsRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={currentTab() === 'env'}>
|
||||
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
|
442
panel/routes/reactions.tsx
Normal file
442
panel/routes/reactions.tsx
Normal file
|
@ -0,0 +1,442 @@
|
|||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query } from '../graphql/generated/schema'
|
||||
import { ADMIN_DELETE_REACTION_MUTATION, ADMIN_RESTORE_REACTION_MUTATION, ADMIN_UPDATE_REACTION_MUTATION } from '../graphql/mutations'
|
||||
import { ADMIN_GET_REACTIONS_QUERY } from '../graphql/queries'
|
||||
import ReactionEditModal from '../modals/ReactionEditModal'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Pagination from '../ui/Pagination'
|
||||
import TableControls from '../ui/TableControls'
|
||||
import { formatDateRelative } from '../utils/date'
|
||||
|
||||
export interface ReactionsRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип реакции для админки
|
||||
*/
|
||||
interface AdminReaction {
|
||||
id: number
|
||||
kind: string
|
||||
body: string
|
||||
created_at: number
|
||||
updated_at?: number
|
||||
deleted_at?: number
|
||||
reply_to?: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
}
|
||||
shout: {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
layout: string
|
||||
created_at: number
|
||||
published_at?: number
|
||||
deleted_at?: number
|
||||
}
|
||||
stat: {
|
||||
comments_count: number
|
||||
rating: number
|
||||
}
|
||||
}
|
||||
|
||||
const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||
console.log('[ReactionsRoute] Initializing...')
|
||||
const [reactions, setReactions] = createSignal<AdminReaction[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [selectedReaction, setSelectedReaction] = createSignal<AdminReaction | null>(null)
|
||||
const [showEditModal, setShowEditModal] = createSignal(false)
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
// Фильтры
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
const [kindFilter, setKindFilter] = createSignal('')
|
||||
const [shoutIdFilter, setShoutIdFilter] = createSignal('')
|
||||
const [statusFilter, setStatusFilter] = createSignal('all')
|
||||
|
||||
/**
|
||||
* Загрузка списка реакций
|
||||
*/
|
||||
async function loadReactions() {
|
||||
console.log('[ReactionsRoute] Loading reactions...')
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await query<{ adminGetReactions: {
|
||||
reactions: AdminReaction[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
totalPages: number
|
||||
} }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_REACTIONS_QUERY,
|
||||
{
|
||||
search: searchQuery(),
|
||||
kind: kindFilter() || undefined,
|
||||
shout_id: shoutIdFilter() ? parseInt(shoutIdFilter()) : undefined,
|
||||
status: statusFilter(),
|
||||
limit: pagination().limit,
|
||||
offset: (pagination().page - 1) * pagination().limit
|
||||
}
|
||||
)
|
||||
if (data?.adminGetReactions?.reactions) {
|
||||
console.log('[ReactionsRoute] Reactions loaded:', data.adminGetReactions.reactions.length)
|
||||
setReactions(data.adminGetReactions.reactions as AdminReaction[])
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.adminGetReactions.total || 0,
|
||||
totalPages: data.adminGetReactions.totalPages || 1
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ReactionsRoute] Failed to load reactions:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список реакций')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет реакцию
|
||||
*/
|
||||
async function updateReaction(reactionData: { id: number; body?: string; deleted_at?: number }) {
|
||||
try {
|
||||
await query(`${location.origin}/graphql`, ADMIN_UPDATE_REACTION_MUTATION, {
|
||||
reaction: reactionData
|
||||
})
|
||||
|
||||
closeEditModal()
|
||||
props.onSuccess?.('Реакция успешно обновлена')
|
||||
void loadReactions()
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления реакции:', err)
|
||||
props.onError?.(err instanceof Error ? err.message : 'Ошибка обновления реакции')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет реакцию
|
||||
*/
|
||||
async function deleteReaction(id: number) {
|
||||
try {
|
||||
await query(`${location.origin}/graphql`, ADMIN_DELETE_REACTION_MUTATION, { reaction_id: id })
|
||||
props.onSuccess?.('Реакция успешно удалена')
|
||||
void loadReactions()
|
||||
} catch (err) {
|
||||
console.error('Ошибка удаления реакции:', err)
|
||||
props.onError?.(err instanceof Error ? err.message : 'Ошибка удаления реакции')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает реакцию
|
||||
*/
|
||||
async function restoreReaction(id: number) {
|
||||
try {
|
||||
await query(`${location.origin}/graphql`, ADMIN_RESTORE_REACTION_MUTATION, { reaction_id: id })
|
||||
props.onSuccess?.('Реакция успешно восстановлена')
|
||||
void loadReactions()
|
||||
} catch (err) {
|
||||
console.error('Ошибка восстановления реакции:', err)
|
||||
props.onError?.(err instanceof Error ? err.message : 'Ошибка восстановления реакции')
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
setShowEditModal(false)
|
||||
setSelectedReaction(null)
|
||||
}
|
||||
|
||||
// Pagination handlers
|
||||
function handlePageChange(page: number) {
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
void loadReactions()
|
||||
}
|
||||
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
void loadReactions()
|
||||
}
|
||||
|
||||
// Search handlers
|
||||
function handleSearchChange(value: string) {
|
||||
setSearchQuery(value)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setPagination((prev) => ({ ...prev, page: 1 }))
|
||||
void loadReactions()
|
||||
}
|
||||
|
||||
// Load reactions on mount
|
||||
onMount(() => {
|
||||
console.log('[ReactionsRoute] Component mounted, loading reactions...')
|
||||
void loadReactions()
|
||||
})
|
||||
|
||||
/**
|
||||
* Получает эмоджи для типа реакции
|
||||
*/
|
||||
const getReactionIcon = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'LIKE':
|
||||
return '👍'
|
||||
case 'DISLIKE':
|
||||
return '👎'
|
||||
case 'COMMENT':
|
||||
return '💬'
|
||||
case 'QUOTE':
|
||||
return '❝'
|
||||
case 'AGREE':
|
||||
return '✅'
|
||||
case 'DISAGREE':
|
||||
return '❌'
|
||||
case 'ASK':
|
||||
return '❓'
|
||||
case 'PROPOSE':
|
||||
return '💡'
|
||||
case 'PROOF':
|
||||
return '🔬'
|
||||
case 'DISPROOF':
|
||||
return '🚫'
|
||||
case 'ACCEPT':
|
||||
return '✔️'
|
||||
case 'REJECT':
|
||||
return '❌'
|
||||
case 'CREDIT':
|
||||
return '🎨'
|
||||
case 'SILENT':
|
||||
return '🤫'
|
||||
default:
|
||||
return '💬'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает название типа реакции на русском
|
||||
*/
|
||||
const getReactionName = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'LIKE':
|
||||
return 'Лайк'
|
||||
case 'DISLIKE':
|
||||
return 'Дизлайк'
|
||||
case 'COMMENT':
|
||||
return 'Комментарий'
|
||||
case 'QUOTE':
|
||||
return 'Цитата'
|
||||
case 'AGREE':
|
||||
return 'Согласен'
|
||||
case 'DISAGREE':
|
||||
return 'Не согласен'
|
||||
case 'ASK':
|
||||
return 'Вопрос'
|
||||
case 'PROPOSE':
|
||||
return 'Предложение'
|
||||
case 'PROOF':
|
||||
return 'Доказательство'
|
||||
case 'DISPROOF':
|
||||
return 'Опровержение'
|
||||
case 'ACCEPT':
|
||||
return 'Принять'
|
||||
case 'REJECT':
|
||||
return 'Отклонить'
|
||||
case 'CREDIT':
|
||||
return 'Упоминание'
|
||||
case 'SILENT':
|
||||
return 'Причастность'
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['reactions-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка данных...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<div class={styles['filters-section']}>
|
||||
<TableControls
|
||||
searchValue={searchQuery()}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearch={handleSearch}
|
||||
searchPlaceholder="Поиск по тексту, автору или публикации..."
|
||||
isLoading={loading()}
|
||||
/>
|
||||
|
||||
<div class={styles['additional-filters']}>
|
||||
<select
|
||||
value={kindFilter()}
|
||||
onChange={(e) => setKindFilter(e.target.value)}
|
||||
class={styles['filter-select']}
|
||||
>
|
||||
<option value="">Все типы</option>
|
||||
<option value="LIKE">Лайк</option>
|
||||
<option value="DISLIKE">Дизлайк</option>
|
||||
<option value="COMMENT">Комментарий</option>
|
||||
<option value="QUOTE">Цитата</option>
|
||||
<option value="AGREE">Согласен</option>
|
||||
<option value="DISAGREE">Не согласен</option>
|
||||
<option value="ASK">Вопрос</option>
|
||||
<option value="PROPOSE">Предложение</option>
|
||||
<option value="PROOF">Доказательство</option>
|
||||
<option value="DISPROOF">Опровержение</option>
|
||||
<option value="ACCEPT">Принять</option>
|
||||
<option value="REJECT">Отклонить</option>
|
||||
<option value="CREDIT">Упоминание</option>
|
||||
<option value="SILENT">Причастность</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
class={styles['filter-select']}
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="deleted">Удаленные</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID публикации"
|
||||
value={shoutIdFilter()}
|
||||
onInput={(e) => setShoutIdFilter(e.target.value)}
|
||||
class={styles['filter-input']}
|
||||
/>
|
||||
|
||||
<Button variant="primary" onClick={() => void loadReactions()}>
|
||||
Применить фильтры
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={reactions().length === 0}>
|
||||
<div class={styles['empty-state']}>Нет данных для отображения</div>
|
||||
</Show>
|
||||
|
||||
<Show when={reactions().length > 0}>
|
||||
<div class={styles['reactions-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Тип</th>
|
||||
<th>Текст</th>
|
||||
<th>Автор</th>
|
||||
<th>Публикация</th>
|
||||
<th>Создано</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={reactions()}>
|
||||
{(reaction) => (
|
||||
<tr
|
||||
class={reaction.deleted_at ? styles['deleted-row'] : ''}
|
||||
onClick={() => {
|
||||
setSelectedReaction(reaction)
|
||||
setShowEditModal(true)
|
||||
}}
|
||||
>
|
||||
<td>{reaction.id}</td>
|
||||
<td>
|
||||
<span title={getReactionName(reaction.kind)} class={styles['reaction-icon']}>
|
||||
{getReactionIcon(reaction.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td class={styles['body-cell']}>
|
||||
<div class={styles['body-preview']}>
|
||||
{reaction.body ? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '') : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class={styles['author-cell']}>
|
||||
<div>{reaction.created_by.name || 'Без имени'}</div>
|
||||
<div class={styles['author-email']}>{reaction.created_by.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class={styles['shout-cell']}>
|
||||
<div class={styles['shout-title']}>
|
||||
{reaction.shout.title.substring(0, 50)}
|
||||
{reaction.shout.title.length > 50 ? '...' : ''}
|
||||
</div>
|
||||
<div class={styles['shout-meta']}>
|
||||
ID: {reaction.shout.id} | {reaction.shout.slug}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDateRelative(reaction.created_at)()}</td>
|
||||
<td>
|
||||
<span class={reaction.deleted_at ? styles['status-deleted'] : styles['status-active']}>
|
||||
{reaction.deleted_at ? 'Удалено' : 'Активно'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
||||
<Show when={reaction.deleted_at}>
|
||||
<Button variant="primary" size="small" onClick={() => restoreReaction(reaction.id)}>
|
||||
Восстановить
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!reaction.deleted_at}>
|
||||
<Button variant="danger" size="small" onClick={() => deleteReaction(reaction.id)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination().page}
|
||||
totalPages={pagination().totalPages}
|
||||
total={pagination().total}
|
||||
limit={pagination().limit}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={showEditModal() && selectedReaction()}>
|
||||
<ReactionEditModal
|
||||
reaction={selectedReaction()!}
|
||||
isOpen={showEditModal()}
|
||||
onClose={closeEditModal}
|
||||
onSave={updateReaction}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReactionsRoute
|
|
@ -274,8 +274,6 @@ main {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.roles-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
@ -334,9 +332,6 @@ main {
|
|||
background-color: white;
|
||||
}
|
||||
|
||||
.shouts-list {
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
@ -584,8 +579,6 @@ td {
|
|||
hyphens: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Responsive Styles */
|
||||
@media (max-width: 1024px) {
|
||||
.header-container {
|
||||
|
@ -677,3 +670,198 @@ td {
|
|||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles for reaction-related components */
|
||||
.reactions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.reactions-list {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.reactions-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* Оптимизация ширины колонок для реакций */
|
||||
.reactions-list th:nth-child(1), /* ID */
|
||||
.reactions-list td:nth-child(1) {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(2), /* ТИП */
|
||||
.reactions-list td:nth-child(2) {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(3), /* ТЕКСТ */
|
||||
.reactions-list td:nth-child(3) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(4), /* АВТОР */
|
||||
.reactions-list td:nth-child(4) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(5), /* ПУБЛИКАЦИЯ */
|
||||
.reactions-list td:nth-child(5) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(6), /* СОЗДАНО */
|
||||
.reactions-list td:nth-child(6) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(7), /* СТАТУС */
|
||||
.reactions-list td:nth-child(7) {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reactions-list th:nth-child(8), /* ДЕЙСТВИЯ */
|
||||
.reactions-list td:nth-child(8) {
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reactions-list th,
|
||||
.reactions-list td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.reactions-list th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reactions-list tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reactions-list tr.deleted-row {
|
||||
background-color: #fff5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.reactions-list tr.deleted-row:hover {
|
||||
background-color: #fed7d7;
|
||||
}
|
||||
|
||||
.body-cell {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.body-preview {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.author-cell {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.author-email {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.shout-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.shout-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.shout-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.actions-cell button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.additional-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-info span {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reaction-icon {
|
||||
font-size: 1.25rem;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -363,6 +363,68 @@
|
|||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
/* HTML Editor в модальных окнах использует стили из Form.module.css */
|
||||
|
||||
.form-textarea {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-family: var(--font-family);
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.stat-info span {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--hover-bg);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--error-color-light, #fef2f2);
|
||||
color: var(--error-color-dark, #dc2626);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--error-color, #fca5a5);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Body Preview Modal */
|
||||
.body-preview {
|
||||
width: 100%;
|
||||
|
|
|
@ -617,3 +617,234 @@ async def admin_get_community_role_settings(_: None, _info: GraphQLResolveInfo,
|
|||
"available_roles": ["reader", "author", "artist", "expert", "editor", "admin"],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
# === РЕАКЦИИ ===
|
||||
|
||||
|
||||
@query.field("adminGetReactions")
|
||||
@admin_auth_required
|
||||
async def admin_get_reactions(
|
||||
_: None,
|
||||
_info: GraphQLResolveInfo,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
search: str = "",
|
||||
kind: str = None,
|
||||
shout_id: int = None,
|
||||
status: str = "all",
|
||||
) -> dict[str, Any]:
|
||||
"""Получает список реакций для админ-панели"""
|
||||
try:
|
||||
from sqlalchemy import and_, case, func, or_
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from auth.orm import Author
|
||||
from orm.reaction import Reaction
|
||||
from orm.shout import Shout
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Базовый запрос с джойнами
|
||||
query = (
|
||||
session.query(Reaction, Author, Shout)
|
||||
.join(Author, Reaction.created_by == Author.id)
|
||||
.join(Shout, Reaction.shout == Shout.id)
|
||||
)
|
||||
|
||||
# Фильтрация
|
||||
filters = []
|
||||
|
||||
# Фильтр по статусу (как в публикациях)
|
||||
if status == "active":
|
||||
filters.append(Reaction.deleted_at.is_(None))
|
||||
elif status == "deleted":
|
||||
filters.append(Reaction.deleted_at.isnot(None))
|
||||
# Если status == "all", не добавляем фильтр - показываем все
|
||||
|
||||
if search:
|
||||
filters.append(
|
||||
or_(
|
||||
Reaction.body.ilike(f"%{search}%"),
|
||||
Author.name.ilike(f"%{search}%"),
|
||||
Author.email.ilike(f"%{search}%"),
|
||||
Shout.title.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
if kind:
|
||||
filters.append(Reaction.kind == kind)
|
||||
if shout_id:
|
||||
filters.append(Reaction.shout == shout_id)
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# Общее количество
|
||||
total = query.count()
|
||||
|
||||
# Получаем реакции с пагинацией
|
||||
reactions_data = query.order_by(Reaction.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Формируем результат
|
||||
reactions = []
|
||||
for reaction, author, shout in reactions_data:
|
||||
# Получаем статистику для каждой реакции
|
||||
aliased_reaction = aliased(Reaction)
|
||||
stats = (
|
||||
session.query(
|
||||
func.count(aliased_reaction.id.distinct()).label("comments_count"),
|
||||
func.sum(
|
||||
case(
|
||||
(aliased_reaction.kind == "LIKE", 1), (aliased_reaction.kind == "DISLIKE", -1), else_=0
|
||||
)
|
||||
).label("rating"),
|
||||
)
|
||||
.filter(
|
||||
aliased_reaction.reply_to == reaction.id,
|
||||
# Убираем фильтр deleted_at чтобы включить все реакции в статистику
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
reactions.append(
|
||||
{
|
||||
"id": reaction.id,
|
||||
"kind": reaction.kind,
|
||||
"body": reaction.body or "",
|
||||
"created_at": reaction.created_at,
|
||||
"updated_at": reaction.updated_at,
|
||||
"deleted_at": reaction.deleted_at,
|
||||
"reply_to": reaction.reply_to,
|
||||
"created_by": {
|
||||
"id": author.id,
|
||||
"name": author.name,
|
||||
"email": author.email,
|
||||
"slug": author.slug,
|
||||
},
|
||||
"shout": {
|
||||
"id": shout.id,
|
||||
"title": shout.title,
|
||||
"slug": shout.slug,
|
||||
"layout": shout.layout,
|
||||
"created_at": shout.created_at,
|
||||
"published_at": shout.published_at,
|
||||
"deleted_at": shout.deleted_at,
|
||||
},
|
||||
"stat": {
|
||||
"comments_count": stats.comments_count or 0,
|
||||
"rating": stats.rating or 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Расчет пагинации
|
||||
per_page = limit
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
page = (offset // per_page) + 1
|
||||
|
||||
logger.info(f"Загружено реакций для админ-панели: {len(reactions)}")
|
||||
return {
|
||||
"reactions": reactions,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
"totalPages": total_pages,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise handle_error("получении списка реакций", e) from e
|
||||
|
||||
|
||||
@mutation.field("adminUpdateReaction")
|
||||
@admin_auth_required
|
||||
async def admin_update_reaction(_: None, _info: GraphQLResolveInfo, reaction: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Обновляет реакцию"""
|
||||
try:
|
||||
import time
|
||||
|
||||
from orm.reaction import Reaction
|
||||
from services.db import local_session
|
||||
|
||||
reaction_id = reaction.get("id")
|
||||
if not reaction_id:
|
||||
return {"success": False, "error": "ID реакции не указан"}
|
||||
|
||||
with local_session() as session:
|
||||
# Находим реакцию
|
||||
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
|
||||
if not db_reaction:
|
||||
return {"success": False, "error": "Реакция не найдена"}
|
||||
|
||||
# Обновляем поля
|
||||
if "body" in reaction:
|
||||
db_reaction.body = reaction["body"]
|
||||
if "deleted_at" in reaction:
|
||||
db_reaction.deleted_at = reaction["deleted_at"]
|
||||
|
||||
# Обновляем время изменения
|
||||
db_reaction.updated_at = int(time.time())
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Реакция {reaction_id} обновлена через админ-панель")
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления реакции: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@mutation.field("adminDeleteReaction")
|
||||
@admin_auth_required
|
||||
async def admin_delete_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]:
|
||||
"""Удаляет реакцию (мягкое удаление)"""
|
||||
try:
|
||||
import time
|
||||
|
||||
from orm.reaction import Reaction
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Находим реакцию
|
||||
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
|
||||
if not db_reaction:
|
||||
return {"success": False, "error": "Реакция не найдена"}
|
||||
|
||||
# Устанавливаем время удаления
|
||||
db_reaction.deleted_at = int(time.time())
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Реакция {reaction_id} удалена через админ-панель")
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления реакции: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@mutation.field("adminRestoreReaction")
|
||||
@admin_auth_required
|
||||
async def admin_restore_reaction(_: None, _info: GraphQLResolveInfo, reaction_id: int) -> dict[str, Any]:
|
||||
"""Восстанавливает удаленную реакцию"""
|
||||
try:
|
||||
from orm.reaction import Reaction
|
||||
from services.db import local_session
|
||||
|
||||
with local_session() as session:
|
||||
# Находим реакцию
|
||||
db_reaction = session.query(Reaction).filter(Reaction.id == reaction_id).first()
|
||||
if not db_reaction:
|
||||
return {"success": False, "error": "Реакция не найдена"}
|
||||
|
||||
# Убираем время удаления
|
||||
db_reaction.deleted_at = None
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Реакция {reaction_id} восстановлена через админ-панель")
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления реакции: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
|
|
@ -227,6 +227,36 @@ type AdminTopicResult {
|
|||
topic: Topic
|
||||
}
|
||||
|
||||
# Типы для управления реакциями
|
||||
type AdminReactionInfo {
|
||||
id: Int!
|
||||
shout: AdminShoutInfo!
|
||||
created_at: Int!
|
||||
created_by: Author!
|
||||
updated_at: Int
|
||||
deleted_at: Int
|
||||
deleted_by: Author
|
||||
kind: ReactionKind!
|
||||
body: String
|
||||
reply_to: Int
|
||||
stat: Stat
|
||||
}
|
||||
|
||||
# Тип для пагинированного ответа реакций
|
||||
type AdminReactionListResponse {
|
||||
reactions: [AdminReactionInfo!]!
|
||||
total: Int!
|
||||
page: Int!
|
||||
perPage: Int!
|
||||
totalPages: Int!
|
||||
}
|
||||
|
||||
input AdminReactionUpdateInput {
|
||||
id: Int!
|
||||
body: String
|
||||
deleted_at: Int
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
getEnvVariables: [EnvSection!]!
|
||||
# Запросы для управления пользователями
|
||||
|
@ -255,6 +285,15 @@ extend type Query {
|
|||
): AdminInviteListResponse!
|
||||
# Запросы для управления топиками
|
||||
adminGetTopics(community_id: Int!): [Topic!]!
|
||||
# Запросы для управления реакциями
|
||||
adminGetReactions(
|
||||
limit: Int
|
||||
offset: Int
|
||||
search: String
|
||||
kind: ReactionKind
|
||||
shout_id: Int
|
||||
status: String
|
||||
): AdminReactionListResponse!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
@ -301,4 +340,8 @@ extend type Mutation {
|
|||
adminUpdateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
||||
adminCreateTopic(topic: AdminTopicInput!): AdminTopicResult!
|
||||
adminMergeTopics(merge_input: TopicMergeInput!): AdminTopicResult!
|
||||
# Admin mutations для управления реакциями
|
||||
adminUpdateReaction(reaction: AdminReactionUpdateInput!): OperationResult!
|
||||
adminDeleteReaction(reaction_id: Int!): OperationResult!
|
||||
adminRestoreReaction(reaction_id: Int!): OperationResult!
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user