feat: Добавил постраничную загрузку отзывов
Frontend CI / build-and-check (push) Failing after 5m10s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
Frontend CI / build-and-check (push) Failing after 5m10s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
|||||||
LocationDto,
|
LocationDto,
|
||||||
PagedResult,
|
PagedResult,
|
||||||
ReviewDto,
|
ReviewDto,
|
||||||
|
ReviewQuery,
|
||||||
SyncResultDto,
|
SyncResultDto,
|
||||||
SyncScheduleRequest,
|
SyncScheduleRequest,
|
||||||
SyncStatusDto,
|
SyncStatusDto,
|
||||||
@@ -115,19 +116,53 @@ export const notificationsApi = {
|
|||||||
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
|
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePagedResult<T>(
|
||||||
|
payload: PagedResult<T> | T[] | undefined,
|
||||||
|
query: { Page?: number; PageSize?: number } = {},
|
||||||
|
): PagedResult<T> {
|
||||||
|
if (!Array.isArray(payload) && payload) return payload
|
||||||
|
|
||||||
|
const items = payload ?? []
|
||||||
|
const page = query.Page ?? 1
|
||||||
|
const pageSize = query.PageSize ?? items.length
|
||||||
|
const totalPages = pageSize > 0 ? Math.ceil(items.length / pageSize) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalCount: items.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listReviewsPage(query: ReviewQuery = {}) {
|
||||||
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews', {
|
||||||
|
query: query as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
return normalizePagedResult(payload, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPendingReviewsPage(query: ReviewQuery = {}) {
|
||||||
|
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending', {
|
||||||
|
query: query as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
return normalizePagedResult(payload, query)
|
||||||
|
}
|
||||||
|
|
||||||
export const reviewsApi = {
|
export const reviewsApi = {
|
||||||
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
||||||
apiRequest<ReviewDto>('/reviews', {
|
apiRequest<ReviewDto>('/reviews', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
|
||||||
}),
|
}),
|
||||||
async list(query: Record<string, unknown> = { PageSize: 100 }) {
|
listPage: listReviewsPage,
|
||||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews', { query })
|
async list(query: ReviewQuery = { PageSize: 100 }) {
|
||||||
return extractItems(payload)
|
return (await listReviewsPage(query)).items
|
||||||
},
|
},
|
||||||
async pending() {
|
pendingPage: listPendingReviewsPage,
|
||||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
async pending(query: ReviewQuery = { PageSize: 100 }) {
|
||||||
return extractItems(payload)
|
return (await listPendingReviewsPage(query)).items
|
||||||
},
|
},
|
||||||
reanalyze: (id: string | number) =>
|
reanalyze: (id: string | number) =>
|
||||||
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export interface CreateLectureRequest {
|
|||||||
onlineUrl?: string | null
|
onlineUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewQuery {
|
||||||
|
Page?: number
|
||||||
|
PageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReviewDto {
|
export interface ReviewDto {
|
||||||
id: number
|
id: number
|
||||||
lectureId: number
|
lectureId: number
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import StatsWidget from '@/components/ui/StatsWidget.vue'
|
|||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
||||||
import type { LectureDto, ReviewDto, SyncStatusDto, UserDto } from '@/api/types'
|
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
||||||
|
|
||||||
const users = ref<UserDto[]>([])
|
const users = ref<UserDto[]>([])
|
||||||
const lectures = ref<LectureDto[]>([])
|
const lectures = ref<LectureDto[]>([])
|
||||||
const reviews = ref<ReviewDto[]>([])
|
const pendingReviewsCount = ref(0)
|
||||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||||
|
|
||||||
const enrollmentCount = computed(() => lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0))
|
const enrollmentCount = computed(() =>
|
||||||
|
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
|
||||||
|
)
|
||||||
const syncMeta = computed(() =>
|
const syncMeta = computed(() =>
|
||||||
syncStatus.value?.lastSyncAt
|
syncStatus.value?.lastSyncAt
|
||||||
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
||||||
@@ -23,12 +25,13 @@ onMounted(async () => {
|
|||||||
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
||||||
usersApi.list({ PageSize: 100 }),
|
usersApi.list({ PageSize: 100 }),
|
||||||
lecturesApi.list({ PageSize: 100 }),
|
lecturesApi.list({ PageSize: 100 }),
|
||||||
reviewsApi.pending(),
|
reviewsApi.pendingPage({ Page: 1, PageSize: 1 }),
|
||||||
syncApi.status(),
|
syncApi.status(),
|
||||||
])
|
])
|
||||||
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
||||||
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
||||||
if (reviewsResult.status === 'fulfilled') reviews.value = reviewsResult.value
|
if (reviewsResult.status === 'fulfilled')
|
||||||
|
pendingReviewsCount.value = reviewsResult.value.totalCount
|
||||||
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -41,7 +44,12 @@ onMounted(async () => {
|
|||||||
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
|
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
|
||||||
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
||||||
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
||||||
<StatsWidget label="Отзывов в LLM" :value="reviews.length" icon="message-circle" color="purple" />
|
<StatsWidget
|
||||||
|
label="Отзывов в LLM"
|
||||||
|
:value="pendingReviewsCount"
|
||||||
|
icon="message-circle"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -49,12 +57,14 @@ onMounted(async () => {
|
|||||||
<div class="section-title">Состояние синхронизации расписания</div>
|
<div class="section-title">Состояние синхронизации расписания</div>
|
||||||
<StatusBadge :status="syncStatus?.status ?? 'pending'" />
|
<StatusBadge :status="syncStatus?.status ?? 'pending'" />
|
||||||
<div class="sync-meta">{{ syncMeta }}</div>
|
<div class="sync-meta">{{ syncMeta }}</div>
|
||||||
<div class="sync-error" v-if="syncStatus?.lastResult?.error">Ошибка: {{ syncStatus.lastResult.error }}</div>
|
<div class="sync-error" v-if="syncStatus?.lastResult?.error">
|
||||||
|
Ошибка: {{ syncStatus.lastResult.error }}
|
||||||
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Очередь LLM-анализа</div>
|
<div class="section-title">Очередь LLM-анализа</div>
|
||||||
<div class="queue-meta">В очереди: {{ reviews.length }} отзывов</div>
|
<div class="queue-meta">В очереди: {{ pendingReviewsCount }} отзывов</div>
|
||||||
<ProgressBar :value="Math.min(reviews.length * 10, 100)" :max="100" />
|
<ProgressBar :value="Math.min(pendingReviewsCount * 10, 100)" :max="100" />
|
||||||
<div class="queue-status">Следующая проверка через 12 минут</div>
|
<div class="queue-status">Следующая проверка через 12 минут</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,11 +72,89 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-dashboard { display: flex; flex-direction: column; gap: 18px; }
|
.admin-dashboard {
|
||||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
display: flex;
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
flex-direction: column;
|
||||||
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
|
gap: 18px;
|
||||||
.sync-error { font-size: 12px; color: var(--color-error); margin-top: 8px; }
|
}
|
||||||
.queue-meta { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 8px; }
|
.stats-row {
|
||||||
.queue-status { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
background: var(--color-black-a08);
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
background: var(--gradient-progress-success);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.percent {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.activity {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.day {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.day-bar {
|
||||||
|
width: 16px;
|
||||||
|
background: var(--gradient-bar-neutral-vertical);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
.sync-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.sync-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-error);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.queue-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.queue-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ const columns = [
|
|||||||
|
|
||||||
const reviews = ref<Review[]>([])
|
const reviews = ref<Review[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
const rows = computed(() =>
|
const rows = computed(() =>
|
||||||
reviews.value.map(review => ({
|
reviews.value.map((review) => ({
|
||||||
id: review.id,
|
id: review.id,
|
||||||
lecture: review.lectureId,
|
lecture: review.lectureId,
|
||||||
student: review.userName,
|
student: review.userName,
|
||||||
@@ -37,11 +41,24 @@ const rows = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pageStart = computed(() =>
|
||||||
|
totalCount.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1,
|
||||||
|
)
|
||||||
|
const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value))
|
||||||
|
const canGoPrev = computed(() => page.value > 1)
|
||||||
|
const canGoNext = computed(() => page.value < totalPages.value)
|
||||||
|
|
||||||
async function fetchPending() {
|
async function fetchPending() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
reviews.value = (await reviewsApi.pending()).map(mapApiReview)
|
const result = await reviewsApi.pendingPage({
|
||||||
|
Page: page.value,
|
||||||
|
PageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
reviews.value = result.items.map(mapApiReview)
|
||||||
|
totalCount.value = result.totalCount
|
||||||
|
totalPages.value = result.totalPages
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,6 +66,12 @@ async function fetchPending() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToPage(nextPage: number) {
|
||||||
|
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
|
||||||
|
page.value = nextPage
|
||||||
|
await fetchPending()
|
||||||
|
}
|
||||||
|
|
||||||
async function reanalyze(id: string) {
|
async function reanalyze(id: string) {
|
||||||
await reviewsApi.reanalyze(id)
|
await reviewsApi.reanalyze(id)
|
||||||
await fetchPending()
|
await fetchPending()
|
||||||
@@ -61,30 +84,87 @@ onMounted(fetchPending)
|
|||||||
<div class="admin-llm page-content">
|
<div class="admin-llm page-content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
|
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
|
||||||
<button class="btn-primary" @click="fetchPending">Обновить очередь</button>
|
<button class="btn-primary" :disabled="loading" @click="fetchPending">
|
||||||
|
{{ loading ? 'Загрузка...' : 'Обновить очередь' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
|
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
|
||||||
<EmptyState v-else-if="!rows.length && !loading" title="Очередь пуста" subtitle="Нет отзывов, ожидающих LLM-анализ." />
|
<EmptyState
|
||||||
<DataTable :columns="columns" :rows="rows">
|
v-else-if="!rows.length && !loading"
|
||||||
<template #status="{ value }">
|
title="Очередь пуста"
|
||||||
<StatusBadge :status="value" />
|
subtitle="Нет отзывов, ожидающих LLM-анализ."
|
||||||
</template>
|
/>
|
||||||
<template #quality="{ value }">
|
<div v-else class="table-section">
|
||||||
<span :class="value >= 0.7 ? 'badge badge-green' : value >= 0.4 ? 'badge badge-orange' : 'badge badge-red'">
|
<div class="pagination-bar">
|
||||||
{{ value }}
|
<span> В очереди {{ totalCount }} отзывов; показаны {{ pageStart }}–{{ pageEnd }} </span>
|
||||||
</span>
|
<div class="pagination-actions">
|
||||||
</template>
|
<button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
|
||||||
<template #actions="{ row }">
|
Назад
|
||||||
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
|
</button>
|
||||||
</template>
|
<span>Страница {{ page }} из {{ totalPages || 1 }}</span>
|
||||||
</DataTable>
|
<button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
|
||||||
|
Вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable :columns="columns" :rows="rows">
|
||||||
|
<template #status="{ value }">
|
||||||
|
<StatusBadge :status="value" />
|
||||||
|
</template>
|
||||||
|
<template #quality="{ value }">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
value >= 0.7
|
||||||
|
? 'badge badge-green'
|
||||||
|
: value >= 0.4
|
||||||
|
? 'badge badge-orange'
|
||||||
|
: 'badge badge-red'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-llm { display: flex; flex-direction: column; gap: 16px; }
|
.admin-llm {
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const ratingLabel: Record<string, string> = {
|
|||||||
|
|
||||||
const reviews = ref<ReviewDto[]>([])
|
const reviews = ref<ReviewDto[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
const reanalyzingId = ref<number | null>(null)
|
const reanalyzingId = ref<number | null>(null)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -63,6 +67,13 @@ const rows = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pageStart = computed(() =>
|
||||||
|
totalCount.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1,
|
||||||
|
)
|
||||||
|
const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value))
|
||||||
|
const canGoPrev = computed(() => page.value > 1)
|
||||||
|
const canGoNext = computed(() => page.value < totalPages.value)
|
||||||
|
|
||||||
function formatQuality(value: number | null | undefined) {
|
function formatQuality(value: number | null | undefined) {
|
||||||
if (value === null || value === undefined) return '—'
|
if (value === null || value === undefined) return '—'
|
||||||
return Number(value).toFixed(2)
|
return Number(value).toFixed(2)
|
||||||
@@ -72,7 +83,13 @@ async function fetchReviews() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
reviews.value = await reviewsApi.list({ PageSize: 100 })
|
const result = await reviewsApi.listPage({
|
||||||
|
Page: page.value,
|
||||||
|
PageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
reviews.value = result.items
|
||||||
|
totalCount.value = result.totalCount
|
||||||
|
totalPages.value = result.totalPages
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить отзывы.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,6 +97,12 @@ async function fetchReviews() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function goToPage(nextPage: number) {
|
||||||
|
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
|
||||||
|
page.value = nextPage
|
||||||
|
await fetchReviews()
|
||||||
|
}
|
||||||
|
|
||||||
async function reanalyze(id: number) {
|
async function reanalyze(id: number) {
|
||||||
reanalyzingId.value = id
|
reanalyzingId.value = id
|
||||||
try {
|
try {
|
||||||
@@ -114,70 +137,88 @@ onMounted(fetchReviews)
|
|||||||
title="Отзывов нет"
|
title="Отзывов нет"
|
||||||
subtitle="Пока нет ни одного отзыва."
|
subtitle="Пока нет ни одного отзыва."
|
||||||
/>
|
/>
|
||||||
<DataTable v-else :columns="columns" :rows="rows">
|
<div v-else class="table-section">
|
||||||
<template #text="{ value }">
|
<div class="pagination-bar">
|
||||||
<span class="review-text" :title="value">{{ value }}</span>
|
<span> Показаны {{ pageStart }}–{{ pageEnd }} из {{ totalCount }} отзывов </span>
|
||||||
</template>
|
<div class="pagination-actions">
|
||||||
<template #status="{ value, row }">
|
<button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
|
||||||
<StatusBadge :status="value" />
|
Назад
|
||||||
<span class="raw-status">{{ row.rawStatus }}</span>
|
</button>
|
||||||
</template>
|
<span>Страница {{ page }} из {{ totalPages || 1 }}</span>
|
||||||
<template #sentiment="{ value }">
|
<button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
|
||||||
<span
|
Вперёд
|
||||||
class="badge"
|
</button>
|
||||||
:class="
|
</div>
|
||||||
value === 'Positive'
|
</div>
|
||||||
? 'badge-green'
|
<DataTable :columns="columns" :rows="rows">
|
||||||
: value === 'Negative'
|
<template #text="{ value }">
|
||||||
? 'badge-red'
|
<span class="review-text" :title="value">{{ value }}</span>
|
||||||
: 'badge-orange'
|
</template>
|
||||||
"
|
<template #status="{ value, row }">
|
||||||
>
|
<StatusBadge :status="value" />
|
||||||
{{ value }}
|
<span class="raw-status">{{ row.rawStatus }}</span>
|
||||||
</span>
|
</template>
|
||||||
</template>
|
<template #sentiment="{ value }">
|
||||||
<template #quality="{ value }">
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
(value ?? 0) >= 0.7
|
|
||||||
? 'badge badge-green'
|
|
||||||
: (value ?? 0) >= 0.4
|
|
||||||
? 'badge badge-orange'
|
|
||||||
: 'badge badge-red'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ formatQuality(value) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #informativeTags="{ row }">
|
|
||||||
<div class="tags-cell">
|
|
||||||
<span
|
<span
|
||||||
class="badge"
|
class="badge"
|
||||||
:class="
|
:class="
|
||||||
row.informative
|
value === 'Positive'
|
||||||
? 'badge-green'
|
? 'badge-green'
|
||||||
: row.informative === false
|
: value === 'Negative'
|
||||||
? 'badge-red'
|
? 'badge-red'
|
||||||
: 'badge-gray'
|
: 'badge-orange'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{ value }}
|
||||||
row.informative === null || row.informative === undefined
|
|
||||||
? '—'
|
|
||||||
: row.informative
|
|
||||||
? 'Informative'
|
|
||||||
: 'Not informative'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
|
</template>
|
||||||
</div>
|
<template #quality="{ value }">
|
||||||
</template>
|
<span
|
||||||
<template #actions="{ row }">
|
:class="
|
||||||
<button class="btn-ghost" :disabled="reanalyzingId === row.id" @click="reanalyze(row.id)">
|
(value ?? 0) >= 0.7
|
||||||
{{ reanalyzingId === row.id ? 'Запускаем...' : 'Повторить анализ' }}
|
? 'badge badge-green'
|
||||||
</button>
|
: (value ?? 0) >= 0.4
|
||||||
</template>
|
? 'badge badge-orange'
|
||||||
</DataTable>
|
: 'badge badge-red'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ formatQuality(value) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #informativeTags="{ row }">
|
||||||
|
<div class="tags-cell">
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="
|
||||||
|
row.informative
|
||||||
|
? 'badge-green'
|
||||||
|
: row.informative === false
|
||||||
|
? 'badge-red'
|
||||||
|
: 'badge-gray'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
row.informative === null || row.informative === undefined
|
||||||
|
? '—'
|
||||||
|
: row.informative
|
||||||
|
? 'Informative'
|
||||||
|
: 'Not informative'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
:disabled="reanalyzingId === row.id"
|
||||||
|
@click="reanalyze(row.id)"
|
||||||
|
>
|
||||||
|
{{ reanalyzingId === row.id ? 'Запускаем...' : 'Повторить анализ' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -213,6 +254,25 @@ onMounted(fetchReviews)
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
.table-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.tags-cell {
|
.tags-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user