This commit is contained in:
root
2025-09-24 10:10:42 +05:00
parent 0f5d0dc806
commit b887e4d652
20 changed files with 4825 additions and 1 deletions
+404
View File
@@ -0,0 +1,404 @@
<template>
<div class="analytics-dashboard">
<!-- Заголовок с кнопкой сбора аналитики -->
<div class="card mb-4">
<div class="card-header">
<h5>Аналитика SMM системы</h5>
<Button icon="pi pi-refresh" label="Собрать аналитику" @click="collectAnalytics" :loading="collecting" class="p-button-info" />
</div>
</div>
<!-- Статистические карточки -->
<div class="grid">
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-chart-line text-blue-500"></i>
</div>
<div class="stat-info">
<h6>Кампании</h6>
<h3>{{ systemStats.campaigns?.total || 0 }}</h3>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-broadcast-tower text-green-500"></i>
</div>
<div class="stat-info">
<h6>Каналы</h6>
<h3>{{ systemStats.channels?.total || 0 }}</h3>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-file text-orange-500"></i>
</div>
<div class="stat-info">
<h6>Контент</h6>
<h3>{{ systemStats.content?.total || 0 }}</h3>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-send text-purple-500"></i>
</div>
<div class="stat-info">
<h6>Опубликовано</h6>
<h3>{{ publishingStats.totalPublished || 0 }}</h3>
</div>
</div>
</div>
</div>
</div>
<!-- Детальная статистика -->
<div class="grid">
<!-- Статистика кампаний -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Статистика кампаний</h6>
</div>
<div class="card-content">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Активные:</span>
<span class="stat-value text-green-600">{{ systemStats.campaigns?.activeCampaigns || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Завершенные:</span>
<span class="stat-value text-blue-600">{{ systemStats.campaigns?.completedCampaigns || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Запланированные:</span>
<span class="stat-value text-orange-600">{{ systemStats.campaigns?.plannedCampaigns || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Общий бюджет:</span>
<span class="stat-value text-purple-600">{{ formatCurrency(systemStats.campaigns?.totalBudget || 0) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика каналов -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Статистика каналов</h6>
</div>
<div class="card-content">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Активные:</span>
<span class="stat-value text-green-600">{{ systemStats.channels?.activeChannels || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Неактивные:</span>
<span class="stat-value text-red-600">{{ systemStats.channels?.inactiveChannels || 0 }}</span>
</div>
<div class="stat-item" v-for="(count, type) in systemStats.channels?.byType" :key="type">
<span class="stat-label">{{ getChannelTypeLabel(type) }}:</span>
<span class="stat-value">{{ count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика контента -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Статистика контента</h6>
</div>
<div class="card-content">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Ожидает одобрения:</span>
<span class="stat-value text-orange-600">{{ systemStats.content?.pendingApproval || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Одобрен:</span>
<span class="stat-value text-blue-600">{{ systemStats.content?.approved || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Опубликован:</span>
<span class="stat-value text-green-600">{{ systemStats.content?.published || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Ошибки:</span>
<span class="stat-value text-red-600">{{ systemStats.content?.failed || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика публикаций -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Статистика публикаций</h6>
</div>
<div class="card-content">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Опубликовано:</span>
<span class="stat-value text-green-600">{{ publishingStats.totalPublished || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Всего контента:</span>
<span class="stat-value">{{ publishingStats.totalContent || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Процент публикации:</span>
<span class="stat-value text-blue-600">{{ publishingStats.publishingRate?.toFixed(1) || 0 }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Время последнего обновления -->
<div class="card mt-4">
<div class="card-content text-center">
<small class="text-600"> Последнее обновление: {{ formatDateTime(systemStats.timestamp) }} </small>
</div>
</div>
</div>
</template>
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import { ChannelType } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'AnalyticsDashboard',
setup() {
const toast = useToast();
// Reactive data
const systemStats = ref({});
const publishingStats = ref({});
const collecting = ref(false);
// Methods
const loadAnalytics = async () => {
try {
const [systemStatsData, publishingStatsData] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats()]);
systemStats.value = systemStatsData;
publishingStats.value = publishingStatsData;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить аналитику: ' + error.message,
life: 3000
});
}
};
const collectAnalytics = async () => {
collecting.value = true;
try {
await AnalyticsService.collectAnalytics();
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Сбор аналитики запущен',
life: 3000
});
// Перезагружаем данные после сбора
setTimeout(() => {
loadAnalytics();
}, 2000);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось запустить сбор аналитики: ' + error.message,
life: 3000
});
} finally {
collecting.value = false;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT'
}).format(value || 0);
};
const formatDateTime = (dateString) => {
if (!dateString) return 'Не определено';
return new Date(dateString).toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getChannelTypeLabel = (type) => {
const labels = {
[ChannelType.TELEGRAM]: 'Telegram',
[ChannelType.VK]: 'VK',
[ChannelType.INSTAGRAM]: 'Instagram'
};
return labels[type] || type;
};
// Lifecycle
onMounted(() => {
loadAnalytics();
});
return {
systemStats,
publishingStats,
collecting,
loadAnalytics,
collectAnalytics,
formatCurrency,
formatDateTime,
getChannelTypeLabel
};
}
};
</script>
<style scoped>
.analytics-dashboard {
padding: 1rem;
}
.stat-card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-content {
display: flex;
align-items: center;
padding: 1.5rem;
}
.stat-icon {
font-size: 2rem;
margin-right: 1rem;
}
.stat-info h6 {
margin: 0 0 0.5rem 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.stat-info h3 {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--text-color);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--surface-border);
}
.card-header h6 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-color);
}
.card-content {
padding: 1.5rem;
}
.stats-grid {
display: grid;
gap: 1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--surface-border);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
font-weight: 500;
color: var(--text-color-secondary);
}
.stat-value {
font-weight: 600;
font-size: 1.125rem;
}
.text-600 {
color: var(--text-color-secondary);
}
.text-center {
text-align: center;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
+399
View File
@@ -0,0 +1,399 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление кампаниями</h5>
<Button icon="pi pi-plus" label="Создать кампанию" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="campaigns"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'goal', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск кампаний..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="goal" header="Цель" :sortable="true">
<template #body="slotProps">
<span>{{ slotProps.data.goal }}</span>
</template>
</Column>
<Column field="budget" header="Бюджет" :sortable="true">
<template #body="slotProps">
<span class="font-semibold text-green-600">
{{ formatCurrency(slotProps.data.budget) }}
</span>
</template>
</Column>
<Column field="startAt" header="Начало" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.startAt) }}</span>
</template>
</Column>
<Column field="endAt" header="Окончание" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.endAt) }}</span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editCampaign(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования кампании -->
<Dialog v-model:visible="showCreateDialog" :header="editingCampaign ? 'Редактировать кампанию' : 'Создать кампанию'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название кампании *</label>
<InputText id="name" v-model="campaignForm.name" :class="{ 'p-invalid': !campaignForm.name }" />
<small v-if="!campaignForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="goal">Цель кампании</label>
<Textarea id="goal" v-model="campaignForm.goal" rows="3" />
</div>
<div class="field">
<label for="budget">Бюджет</label>
<InputNumber id="budget" v-model="campaignForm.budget" mode="currency" currency="KZT" locale="ru-RU" :min="0" />
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="startAt">Дата начала</label>
<Calendar id="startAt" v-model="campaignForm.startAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="endAt">Дата окончания</label>
<Calendar id="endAt" v-model="campaignForm.endAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
</div>
<div class="field">
<label for="status">Статус</label>
<Dropdown id="status" v-model="campaignForm.status" :options="statusOptions" optionLabel="label" optionValue="value" />
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingCampaign ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveCampaign" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить кампанию "{{ campaignToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteCampaign" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import CampaignService from '@/service/CampaignService.js';
import { CampaignStatus } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, reactive, ref } from 'vue';
export default {
name: 'CampaignList',
setup() {
const toast = useToast();
// Reactive data
const campaigns = ref([]);
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const showCreateDialog = ref(false);
const showDeleteDialog = ref(false);
const editingCampaign = ref(null);
const campaignToDelete = ref(null);
const filters = ref({
global: { value: null }
});
// Campaign form
const campaignForm = reactive({
name: '',
goal: '',
budget: 0,
startAt: null,
endAt: null,
status: CampaignStatus.PLANNED
});
// Status options
const statusOptions = [
{ label: 'Запланирована', value: CampaignStatus.PLANNED },
{ label: 'Активна', value: CampaignStatus.ACTIVE },
{ label: 'Завершена', value: CampaignStatus.COMPLETED }
];
// Methods
const loadCampaigns = async () => {
loading.value = true;
try {
campaigns.value = await CampaignService.getAllCampaigns();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить кампании: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const editCampaign = (campaign) => {
editingCampaign.value = campaign;
Object.assign(campaignForm, {
name: campaign.name,
goal: campaign.goal,
budget: campaign.budget,
startAt: campaign.startAt ? new Date(campaign.startAt) : null,
endAt: campaign.endAt ? new Date(campaign.endAt) : null,
status: campaign.status
});
showCreateDialog.value = true;
};
const saveCampaign = async () => {
if (!campaignForm.name) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Название кампании обязательно',
life: 3000
});
return;
}
saving.value = true;
try {
const data = {
...campaignForm,
startAt: campaignForm.startAt ? campaignForm.startAt.toISOString() : null,
endAt: campaignForm.endAt ? campaignForm.endAt.toISOString() : null
};
if (editingCampaign.value) {
await CampaignService.updateCampaign(editingCampaign.value.id, data);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Кампания обновлена',
life: 3000
});
} else {
await CampaignService.createCampaign(data);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Кампания создана',
life: 3000
});
}
await loadCampaigns();
cancelEdit();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось сохранить кампанию: ' + error.message,
life: 3000
});
} finally {
saving.value = false;
}
};
const cancelEdit = () => {
editingCampaign.value = null;
showCreateDialog.value = false;
Object.assign(campaignForm, {
name: '',
goal: '',
budget: 0,
startAt: null,
endAt: null,
status: CampaignStatus.PLANNED
});
};
const confirmDelete = (campaign) => {
campaignToDelete.value = campaign;
showDeleteDialog.value = true;
};
const deleteCampaign = async () => {
deleting.value = true;
try {
await CampaignService.deleteCampaign(campaignToDelete.value.id);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Кампания удалена',
life: 3000
});
await loadCampaigns();
showDeleteDialog.value = false;
campaignToDelete.value = null;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось удалить кампанию: ' + error.message,
life: 3000
});
} finally {
deleting.value = false;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT'
}).format(value || 0);
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getStatusLabel = (status) => {
const labels = {
[CampaignStatus.PLANNED]: 'Запланирована',
[CampaignStatus.ACTIVE]: 'Активна',
[CampaignStatus.COMPLETED]: 'Завершена'
};
return labels[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
[CampaignStatus.PLANNED]: 'info',
[CampaignStatus.ACTIVE]: 'success',
[CampaignStatus.COMPLETED]: 'secondary'
};
return severities[status] || 'info';
};
// Lifecycle
onMounted(() => {
loadCampaigns();
});
return {
campaigns,
loading,
saving,
deleting,
showCreateDialog,
showDeleteDialog,
editingCampaign,
campaignToDelete,
filters,
campaignForm,
statusOptions,
loadCampaigns,
editCampaign,
saveCampaign,
cancelEdit,
confirmDelete,
deleteCampaign,
formatCurrency,
formatDate,
getStatusLabel,
getStatusSeverity
};
}
};
</script>
<style scoped>
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--surface-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content {
padding: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
}
</style>
+347
View File
@@ -0,0 +1,347 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление каналами</h5>
<Button icon="pi pi-plus" label="Создать канал" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="channels"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'type']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск каналов..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="type" header="Тип" :sortable="true">
<template #body="slotProps">
<Tag :value="getTypeLabel(slotProps.data.type)" :severity="getTypeSeverity(slotProps.data.type)" />
</template>
</Column>
<Column field="apiKeyRef" header="API Ключ" :sortable="true">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.apiKeyRef }}</span>
</template>
</Column>
<Column field="isActive" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.isActive ? 'Активен' : 'Неактивен'" :severity="slotProps.data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editChannel(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования канала -->
<Dialog v-model:visible="showCreateDialog" :header="editingChannel ? 'Редактировать канал' : 'Создать канал'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название канала *</label>
<InputText id="name" v-model="channelForm.name" :class="{ 'p-invalid': !channelForm.name }" />
<small v-if="!channelForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="type">Тип канала *</label>
<Dropdown id="type" v-model="channelForm.type" :options="typeOptions" optionLabel="label" optionValue="value" :class="{ 'p-invalid': !channelForm.type }" />
<small v-if="!channelForm.type" class="p-error">Тип канала обязателен</small>
</div>
<div class="field">
<label for="apiKeyRef">API Ключ *</label>
<InputText id="apiKeyRef" v-model="channelForm.apiKeyRef" :class="{ 'p-invalid': !channelForm.apiKeyRef }" />
<small v-if="!channelForm.apiKeyRef" class="p-error">API ключ обязателен</small>
<small class="text-600">Ссылка на API ключ в системе</small>
</div>
<div class="field">
<div class="flex align-items-center">
<Checkbox id="isActive" v-model="channelForm.isActive" :binary="true" />
<label for="isActive" class="ml-2">Канал активен</label>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingChannel ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveChannel" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить канал "{{ channelToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteChannel" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import ChannelService from '@/service/ChannelService.js';
import { ChannelType } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, reactive, ref } from 'vue';
export default {
name: 'ChannelList',
setup() {
const toast = useToast();
// Reactive data
const channels = ref([]);
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const showCreateDialog = ref(false);
const showDeleteDialog = ref(false);
const editingChannel = ref(null);
const channelToDelete = ref(null);
const filters = ref({
global: { value: null }
});
// Channel form
const channelForm = reactive({
name: '',
type: '',
apiKeyRef: '',
isActive: true
});
// Type options
const typeOptions = [
{ label: 'Telegram', value: ChannelType.TELEGRAM },
{ label: 'VK', value: ChannelType.VK },
{ label: 'Instagram', value: ChannelType.INSTAGRAM }
];
// Methods
const loadChannels = async () => {
loading.value = true;
try {
channels.value = await ChannelService.getAllChannels();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить каналы: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const editChannel = (channel) => {
editingChannel.value = channel;
Object.assign(channelForm, {
name: channel.name,
type: channel.type,
apiKeyRef: channel.apiKeyRef,
isActive: channel.isActive
});
showCreateDialog.value = true;
};
const saveChannel = async () => {
if (!channelForm.name || !channelForm.type || !channelForm.apiKeyRef) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Все обязательные поля должны быть заполнены',
life: 3000
});
return;
}
saving.value = true;
try {
if (editingChannel.value) {
await ChannelService.updateChannel(editingChannel.value.id, channelForm);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Канал обновлен',
life: 3000
});
} else {
await ChannelService.createChannel(channelForm);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Канал создан',
life: 3000
});
}
await loadChannels();
cancelEdit();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось сохранить канал: ' + error.message,
life: 3000
});
} finally {
saving.value = false;
}
};
const cancelEdit = () => {
editingChannel.value = null;
showCreateDialog.value = false;
Object.assign(channelForm, {
name: '',
type: '',
apiKeyRef: '',
isActive: true
});
};
const confirmDelete = (channel) => {
channelToDelete.value = channel;
showDeleteDialog.value = true;
};
const deleteChannel = async () => {
deleting.value = true;
try {
await ChannelService.deleteChannel(channelToDelete.value.id);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Канал удален',
life: 3000
});
await loadChannels();
showDeleteDialog.value = false;
channelToDelete.value = null;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось удалить канал: ' + error.message,
life: 3000
});
} finally {
deleting.value = false;
}
};
const getTypeLabel = (type) => {
const labels = {
[ChannelType.TELEGRAM]: 'Telegram',
[ChannelType.VK]: 'VK',
[ChannelType.INSTAGRAM]: 'Instagram'
};
return labels[type] || type;
};
const getTypeSeverity = (type) => {
const severities = {
[ChannelType.TELEGRAM]: 'info',
[ChannelType.VK]: 'warning',
[ChannelType.INSTAGRAM]: 'success'
};
return severities[type] || 'info';
};
// Lifecycle
onMounted(() => {
loadChannels();
});
return {
channels,
loading,
saving,
deleting,
showCreateDialog,
showDeleteDialog,
editingChannel,
channelToDelete,
filters,
channelForm,
typeOptions,
loadChannels,
editChannel,
saveChannel,
cancelEdit,
confirmDelete,
deleteChannel,
getTypeLabel,
getTypeSeverity
};
}
};
</script>
<style scoped>
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--surface-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content {
padding: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
}
.text-600 {
color: var(--text-color-secondary);
}
</style>
+552
View File
@@ -0,0 +1,552 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление контентом</h5>
<Button icon="pi pi-plus" label="Создать контент" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="content"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['topic', 'locale', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск контента..." />
</span>
</div>
</template>
<Column field="topic" header="Тема" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.topic }}</span>
</template>
</Column>
<Column field="locale" header="Локаль" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.locale" severity="info" />
</template>
</Column>
<Column field="campaignId" header="Кампания" :sortable="true">
<template #body="slotProps">
<span>{{ getCampaignName(slotProps.data.campaignId) }}</span>
</template>
</Column>
<Column field="postDraft" header="Черновик" :sortable="true">
<template #body="slotProps">
<span class="text-sm">{{ truncateText(slotProps.data.postDraft, 50) }}</span>
</template>
</Column>
<Column field="scheduledAt" header="Запланировано" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.scheduledAt) }}</span>
</template>
</Column>
<Column field="priority" header="Приоритет" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.priority" :severity="getPrioritySeverity(slotProps.data.priority)" />
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" class="p-button-rounded p-button-text p-button-sm" @click="viewContent(slotProps.data)" v-tooltip.top="'Просмотр'" />
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editContent(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button v-if="slotProps.data.status === 'PENDING_APPROVAL'" icon="pi pi-check" class="p-button-rounded p-button-text p-button-success p-button-sm" @click="approveContent(slotProps.data)" v-tooltip.top="'Одобрить'" />
<Button v-if="slotProps.data.status === 'APPROVED'" icon="pi pi-send" class="p-button-rounded p-button-text p-button-info p-button-sm" @click="publishContent(slotProps.data)" v-tooltip.top="'Опубликовать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования контента -->
<Dialog v-model:visible="showCreateDialog" :header="editingContent ? 'Редактировать контент' : 'Создать контент'" :modal="true" :style="{ width: '70vw' }" :closable="false">
<div class="p-fluid">
<div class="grid">
<div class="col-6">
<div class="field">
<label for="campaignId">Кампания *</label>
<Dropdown id="campaignId" v-model="contentForm.campaignId" :options="campaigns" optionLabel="name" optionValue="id" :class="{ 'p-invalid': !contentForm.campaignId }" />
<small v-if="!contentForm.campaignId" class="p-error">Кампания обязательна</small>
</div>
</div>
<div class="col-6">
<div class="field">
<label for="locale">Локаль *</label>
<InputText id="locale" v-model="contentForm.locale" :class="{ 'p-invalid': !contentForm.locale }" />
<small v-if="!contentForm.locale" class="p-error">Локаль обязательна</small>
</div>
</div>
</div>
<div class="field">
<label for="topic">Тема *</label>
<InputText id="topic" v-model="contentForm.topic" :class="{ 'p-invalid': !contentForm.topic }" />
<small v-if="!contentForm.topic" class="p-error">Тема обязательна</small>
</div>
<div class="field">
<label for="postDraft">Черновик поста</label>
<Textarea id="postDraft" v-model="contentForm.postDraft" rows="4" />
</div>
<div class="field">
<label for="assetsRefs">Ссылки на ресурсы</label>
<InputText id="assetsRefs" v-model="contentForm.assetsRefs" />
<small class="text-600">Разделите ссылки запятыми</small>
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="scheduledAt">Время публикации</label>
<Calendar id="scheduledAt" v-model="contentForm.scheduledAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="priority">Приоритет</label>
<InputNumber id="priority" v-model="contentForm.priority" :min="1" :max="10" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingContent ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveContent" :loading="saving" />
</template>
</Dialog>
<!-- Диалог просмотра контента -->
<Dialog v-model:visible="showViewDialog" header="Просмотр контента" :modal="true" :style="{ width: '60vw' }">
<div v-if="viewingContent" class="content-view">
<div class="field">
<label>Тема:</label>
<p class="font-semibold">{{ viewingContent.topic }}</p>
</div>
<div class="field">
<label>Локаль:</label>
<p>{{ viewingContent.locale }}</p>
</div>
<div class="field">
<label>Кампания:</label>
<p>{{ getCampaignName(viewingContent.campaignId) }}</p>
</div>
<div class="field">
<label>Черновик поста:</label>
<p class="whitespace-pre-wrap">{{ viewingContent.postDraft }}</p>
</div>
<div class="field">
<label>Ссылки на ресурсы:</label>
<p>{{ viewingContent.assetsRefs }}</p>
</div>
<div class="field">
<label>Время публикации:</label>
<p>{{ formatDate(viewingContent.scheduledAt) }}</p>
</div>
<div class="field">
<label>Приоритет:</label>
<p>{{ viewingContent.priority }}</p>
</div>
<div class="field">
<label>Статус:</label>
<Tag :value="getStatusLabel(viewingContent.status)" :severity="getStatusSeverity(viewingContent.status)" />
</div>
</div>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить контент "{{ contentToDelete?.topic }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteContent" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import CampaignService from '@/service/CampaignService.js';
import ContentService from '@/service/ContentService.js';
import PublishingService from '@/service/PublishingService.js';
import { ContentStatus } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, reactive, ref } from 'vue';
export default {
name: 'ContentList',
setup() {
const toast = useToast();
// Reactive data
const content = ref([]);
const campaigns = ref([]);
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const showCreateDialog = ref(false);
const showViewDialog = ref(false);
const showDeleteDialog = ref(false);
const editingContent = ref(null);
const viewingContent = ref(null);
const contentToDelete = ref(null);
const filters = ref({
global: { value: null }
});
// Content form
const contentForm = reactive({
campaignId: '',
locale: '',
topic: '',
postDraft: '',
assetsRefs: '',
scheduledAt: null,
priority: 1
});
// Methods
const loadContent = async () => {
loading.value = true;
try {
content.value = await ContentService.getAllContent();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить контент: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const loadCampaigns = async () => {
try {
campaigns.value = await CampaignService.getAllCampaigns();
} catch (error) {
console.error('Error loading campaigns:', error);
}
};
const viewContent = (contentItem) => {
viewingContent.value = contentItem;
showViewDialog.value = true;
};
const editContent = (contentItem) => {
editingContent.value = contentItem;
Object.assign(contentForm, {
campaignId: contentItem.campaignId,
locale: contentItem.locale,
topic: contentItem.topic,
postDraft: contentItem.postDraft,
assetsRefs: contentItem.assetsRefs,
scheduledAt: contentItem.scheduledAt ? new Date(contentItem.scheduledAt) : null,
priority: contentItem.priority
});
showCreateDialog.value = true;
};
const saveContent = async () => {
if (!contentForm.campaignId || !contentForm.locale || !contentForm.topic) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Все обязательные поля должны быть заполнены',
life: 3000
});
return;
}
saving.value = true;
try {
const data = {
...contentForm,
scheduledAt: contentForm.scheduledAt ? contentForm.scheduledAt.toISOString() : null
};
if (editingContent.value) {
await ContentService.updateContent(editingContent.value.id, data);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Контент обновлен',
life: 3000
});
} else {
await ContentService.createContent(data);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Контент создан',
life: 3000
});
}
await loadContent();
cancelEdit();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось сохранить контент: ' + error.message,
life: 3000
});
} finally {
saving.value = false;
}
};
const approveContent = async (contentItem) => {
try {
await ContentService.approveContent(contentItem.id);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Контент одобрен',
life: 3000
});
await loadContent();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось одобрить контент: ' + error.message,
life: 3000
});
}
};
const publishContent = async (contentItem) => {
try {
await PublishingService.publishContent(contentItem.id);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Контент опубликован',
life: 3000
});
await loadContent();
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось опубликовать контент: ' + error.message,
life: 3000
});
}
};
const cancelEdit = () => {
editingContent.value = null;
showCreateDialog.value = false;
Object.assign(contentForm, {
campaignId: '',
locale: '',
topic: '',
postDraft: '',
assetsRefs: '',
scheduledAt: null,
priority: 1
});
};
const confirmDelete = (contentItem) => {
contentToDelete.value = contentItem;
showDeleteDialog.value = true;
};
const deleteContent = async () => {
deleting.value = true;
try {
await ContentService.deleteContent(contentToDelete.value.id);
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Контент удален',
life: 3000
});
await loadContent();
showDeleteDialog.value = false;
contentToDelete.value = null;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось удалить контент: ' + error.message,
life: 3000
});
} finally {
deleting.value = false;
}
};
const getCampaignName = (campaignId) => {
const campaign = campaigns.value.find((c) => c.id === campaignId);
return campaign ? campaign.name : campaignId;
};
const truncateText = (text, length) => {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getStatusLabel = (status) => {
const labels = {
[ContentStatus.DRAFT]: 'Черновик',
[ContentStatus.PENDING_APPROVAL]: 'Ожидает одобрения',
[ContentStatus.APPROVED]: 'Одобрен',
[ContentStatus.PUBLISHED]: 'Опубликован',
[ContentStatus.FAILED]: 'Ошибка'
};
return labels[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
[ContentStatus.DRAFT]: 'info',
[ContentStatus.PENDING_APPROVAL]: 'warning',
[ContentStatus.APPROVED]: 'success',
[ContentStatus.PUBLISHED]: 'success',
[ContentStatus.FAILED]: 'danger'
};
return severities[status] || 'info';
};
const getPrioritySeverity = (priority) => {
if (priority >= 8) return 'danger';
if (priority >= 6) return 'warning';
if (priority >= 4) return 'info';
return 'success';
};
// Lifecycle
onMounted(() => {
loadContent();
loadCampaigns();
});
return {
content,
campaigns,
loading,
saving,
deleting,
showCreateDialog,
showViewDialog,
showDeleteDialog,
editingContent,
viewingContent,
contentToDelete,
filters,
contentForm,
loadContent,
loadCampaigns,
viewContent,
editContent,
saveContent,
approveContent,
publishContent,
cancelEdit,
confirmDelete,
deleteContent,
getCampaignName,
truncateText,
formatDate,
getStatusLabel,
getStatusSeverity,
getPrioritySeverity
};
}
};
</script>
<style scoped>
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--surface-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-content {
padding: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
}
.text-600 {
color: var(--text-color-secondary);
}
.content-view .field {
margin-bottom: 1.5rem;
}
.content-view label {
font-weight: 600;
color: var(--text-color-secondary);
display: block;
margin-bottom: 0.5rem;
}
.whitespace-pre-wrap {
white-space: pre-wrap;
}
</style>
+428
View File
@@ -0,0 +1,428 @@
<template>
<div class="smm-dashboard">
<div class="grid">
<!-- Заголовок -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>SMM Система Konturai</h4>
<p class="text-600">Управление социальными сетями и контентом</p>
</div>
</div>
</div>
<!-- Быстрые действия -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Быстрые действия</h5>
</div>
<div class="card-content">
<div class="grid">
<div class="col-12 md:col-3">
<Button label="Создать кампанию" icon="pi pi-plus" class="w-full p-button-success" @click="$router.push('/smm/campaigns')" />
</div>
<div class="col-12 md:col-3">
<Button label="Добавить канал" icon="pi pi-plus" class="w-full p-button-info" @click="$router.push('/smm/channels')" />
</div>
<div class="col-12 md:col-3">
<Button label="Создать контент" icon="pi pi-plus" class="w-full p-button-warning" @click="$router.push('/smm/content')" />
</div>
<div class="col-12 md:col-3">
<Button label="Просмотр аналитики" icon="pi pi-chart-line" class="w-full p-button-secondary" @click="$router.push('/smm/analytics')" />
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-megaphone text-blue-500"></i>
</div>
<div class="stat-info">
<h6>Кампании</h6>
<h3>{{ stats.campaigns || 0 }}</h3>
<small class="text-600">{{ stats.activeCampaigns || 0 }} активных</small>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-broadcast-tower text-green-500"></i>
</div>
<div class="stat-info">
<h6>Каналы</h6>
<h3>{{ stats.channels || 0 }}</h3>
<small class="text-600">{{ stats.activeChannels || 0 }} активных</small>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-file-edit text-orange-500"></i>
</div>
<div class="stat-info">
<h6>Контент</h6>
<h3>{{ stats.content || 0 }}</h3>
<small class="text-600">{{ stats.pendingApproval || 0 }} ожидает одобрения</small>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-3">
<div class="card stat-card">
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-send text-purple-500"></i>
</div>
<div class="stat-info">
<h6>Опубликовано</h6>
<h3>{{ stats.published || 0 }}</h3>
<small class="text-600">{{ stats.publishingRate?.toFixed(1) || 0 }}% успешность</small>
</div>
</div>
</div>
</div>
<!-- Последние кампании -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Последние кампании</h6>
<Button label="Все кампании" icon="pi pi-arrow-right" class="p-button-text p-button-sm" @click="$router.push('/smm/campaigns')" />
</div>
<div class="card-content">
<div v-if="loading" class="text-center">
<ProgressSpinner />
</div>
<div v-else-if="recentCampaigns.length === 0" class="text-center text-600">
<i class="pi pi-info-circle"></i>
<p>Нет кампаний</p>
</div>
<div v-else class="campaign-list">
<div v-for="campaign in recentCampaigns" :key="campaign.id" class="campaign-item">
<div class="campaign-info">
<h6>{{ campaign.name }}</h6>
<p class="text-600">{{ campaign.goal }}</p>
<small>{{ formatDate(campaign.startAt) }} - {{ formatDate(campaign.endAt) }}</small>
</div>
<Tag :value="getStatusLabel(campaign.status)" :severity="getStatusSeverity(campaign.status)" />
</div>
</div>
</div>
</div>
</div>
<!-- Последний контент -->
<div class="col-12 lg:col-6">
<div class="card">
<div class="card-header">
<h6>Последний контент</h6>
<Button label="Весь контент" icon="pi pi-arrow-right" class="p-button-text p-button-sm" @click="$router.push('/smm/content')" />
</div>
<div class="card-content">
<div v-if="loading" class="text-center">
<ProgressSpinner />
</div>
<div v-else-if="recentContent.length === 0" class="text-center text-600">
<i class="pi pi-info-circle"></i>
<p>Нет контента</p>
</div>
<div v-else class="content-list">
<div v-for="content in recentContent" :key="content.id" class="content-item">
<div class="content-info">
<h6>{{ content.topic }}</h6>
<p class="text-600">{{ truncateText(content.postDraft, 50) }}</p>
<small>{{ formatDate(content.scheduledAt) }}</small>
</div>
<Tag :value="getContentStatusLabel(content.status)" :severity="getContentStatusSeverity(content.status)" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import CampaignService from '@/service/CampaignService.js';
import ContentService from '@/service/ContentService.js';
import { CampaignStatus, ContentStatus } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'SmmDashboard',
setup() {
const toast = useToast();
// Reactive data
const stats = ref({});
const recentCampaigns = ref([]);
const recentContent = ref([]);
const loading = ref(false);
// Methods
const loadDashboardData = async () => {
loading.value = true;
try {
const [systemStats, publishingStats, campaigns, content] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats(), CampaignService.getAllCampaigns(), ContentService.getAllContent()]);
stats.value = {
campaigns: systemStats.campaigns?.total || 0,
activeCampaigns: systemStats.campaigns?.activeCampaigns || 0,
channels: systemStats.channels?.total || 0,
activeChannels: systemStats.channels?.activeChannels || 0,
content: systemStats.content?.total || 0,
pendingApproval: systemStats.content?.pendingApproval || 0,
published: publishingStats.totalPublished || 0,
publishingRate: publishingStats.publishingRate || 0
};
// Получаем последние 5 кампаний
recentCampaigns.value = campaigns.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)).slice(0, 5);
// Получаем последние 5 контентов
recentContent.value = content.sort((a, b) => new Date(b.scheduledAt || b.createdAt) - new Date(a.scheduledAt || a.createdAt)).slice(0, 5);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить данные: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const truncateText = (text, length) => {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
};
const getStatusLabel = (status) => {
const labels = {
[CampaignStatus.PLANNED]: 'Запланирована',
[CampaignStatus.ACTIVE]: 'Активна',
[CampaignStatus.COMPLETED]: 'Завершена'
};
return labels[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
[CampaignStatus.PLANNED]: 'info',
[CampaignStatus.ACTIVE]: 'success',
[CampaignStatus.COMPLETED]: 'secondary'
};
return severities[status] || 'info';
};
const getContentStatusLabel = (status) => {
const labels = {
[ContentStatus.DRAFT]: 'Черновик',
[ContentStatus.PENDING_APPROVAL]: 'Ожидает',
[ContentStatus.APPROVED]: 'Одобрен',
[ContentStatus.PUBLISHED]: 'Опубликован',
[ContentStatus.FAILED]: 'Ошибка'
};
return labels[status] || status;
};
const getContentStatusSeverity = (status) => {
const severities = {
[ContentStatus.DRAFT]: 'info',
[ContentStatus.PENDING_APPROVAL]: 'warning',
[ContentStatus.APPROVED]: 'success',
[ContentStatus.PUBLISHED]: 'success',
[ContentStatus.FAILED]: 'danger'
};
return severities[status] || 'info';
};
// Lifecycle
onMounted(() => {
loadDashboardData();
});
return {
stats,
recentCampaigns,
recentContent,
loading,
formatDate,
truncateText,
getStatusLabel,
getStatusSeverity,
getContentStatusLabel,
getContentStatusSeverity
};
}
};
</script>
<style scoped>
.smm-dashboard {
padding: 1rem;
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid var(--surface-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h4 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
}
.card-header h5 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.card-header h6 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-color);
}
.card-content {
padding: 1.5rem;
}
.stat-card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-content {
display: flex;
align-items: center;
padding: 1.5rem;
}
.stat-icon {
font-size: 2rem;
margin-right: 1rem;
}
.stat-info h6 {
margin: 0 0 0.5rem 0;
color: var(--text-color-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.stat-info h3 {
margin: 0 0 0.25rem 0;
font-size: 2rem;
font-weight: 700;
color: var(--text-color);
}
.campaign-list,
.content-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.campaign-item,
.content-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-50);
}
.campaign-info,
.content-info {
flex: 1;
}
.campaign-info h6,
.content-info h6 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.campaign-info p,
.content-info p {
margin: 0 0 0.25rem 0;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.campaign-info small,
.content-info small {
font-size: 0.75rem;
color: var(--text-color-secondary);
}
.text-600 {
color: var(--text-color-secondary);
}
.text-center {
text-align: center;
}
.w-full {
width: 100%;
}
</style>