3fdcd88ad3
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса» не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета. Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов. Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт. Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице «Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов, их клиент разбирает сам. Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены. 1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам, 500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17: право на запись плюс нумератор. В плане про это было написано прямым текстом, я прошёл мимо. Тесты поймать не могли: ходят суперпользователем. 2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» — это норма, так и есть живьём. 3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит теперь сам джоб: показываем один раз, пока сказать нечего. 4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите. В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её больше не берёт. Сценарий существует только при частичном отказе — тест переписан на два объявления и теперь вырезание защиты его роняет. Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
551 lines
22 KiB
Vue
551 lines
22 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Админка SaaS → Реклама: расход и маржа по тенантам.
|
||
*
|
||
* Сколько тенанты тратят на рекламу (client_spend), сколько из этого мы
|
||
* платим Яндексу за закупку (yandex_cost) и что остаётся нам маржой
|
||
* (our_margin). Источник данных: GET /api/admin/advertising/spend.
|
||
*/
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||
import * as adminApi from '../../api/admin';
|
||
import type { AdminAwaitingCampaign, AdminStuckRobotJob, AdvertisingSpendPeriod } from '../../api/admin';
|
||
import { extractErrorMessage } from '../../api/client';
|
||
import { formatPlain } from '../../composables/billingFormatters';
|
||
|
||
type SpendRow = {
|
||
tenant_id: number;
|
||
tenant_name: string;
|
||
client_spend_rub: number;
|
||
yandex_cost_rub: number;
|
||
our_margin_rub: number;
|
||
};
|
||
|
||
const periodOptions: { value: AdvertisingSpendPeriod; label: string }[] = [
|
||
{ value: 'current_month', label: 'Текущий месяц' },
|
||
{ value: 'last_month', label: 'Прошлый месяц' },
|
||
{ value: '90d', label: '90 дней' },
|
||
{ value: 'all', label: 'Всё время' },
|
||
];
|
||
|
||
const period = ref<AdvertisingSpendPeriod>('current_month');
|
||
const rows = reactive<SpendRow[]>([]);
|
||
const totals = reactive({
|
||
client_spend_rub: 0,
|
||
yandex_cost_rub: 0,
|
||
our_margin_rub: 0,
|
||
});
|
||
const adMarginPercent = ref('');
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
const errorMessage = ref('');
|
||
|
||
const cpm = ref('');
|
||
const margin = ref('');
|
||
const cpmSaving = ref(false);
|
||
const cpmSaved = ref(false);
|
||
const cpmError = ref('');
|
||
|
||
async function loadCpm() {
|
||
try {
|
||
const res = await adminApi.fetchAdSettings();
|
||
cpm.value = res.client_cpm_rub;
|
||
margin.value = res.ad_margin_percent;
|
||
} catch (e) {
|
||
cpmError.value = extractErrorMessage(e);
|
||
}
|
||
}
|
||
|
||
async function saveCpm() {
|
||
cpmSaving.value = true;
|
||
cpmError.value = '';
|
||
cpmSaved.value = false;
|
||
try {
|
||
await adminApi.updateAdSettings({ client_cpm_rub: cpm.value, ad_margin_percent: margin.value });
|
||
cpmSaved.value = true;
|
||
} catch (e) {
|
||
cpmError.value = extractErrorMessage(e);
|
||
} finally {
|
||
cpmSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadSpend() {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
errorMessage.value = '';
|
||
try {
|
||
const res = await adminApi.fetchAdvertisingSpend(period.value);
|
||
const mapped: SpendRow[] = res.data.map((r) => ({
|
||
tenant_id: r.tenant_id,
|
||
tenant_name: r.tenant_name,
|
||
client_spend_rub: parseFloat(r.client_spend_rub),
|
||
yandex_cost_rub: parseFloat(r.yandex_cost_rub),
|
||
our_margin_rub: parseFloat(r.our_margin_rub),
|
||
}));
|
||
rows.splice(0, rows.length, ...mapped);
|
||
totals.client_spend_rub = parseFloat(res.totals.client_spend_rub);
|
||
totals.yandex_cost_rub = parseFloat(res.totals.yandex_cost_rub);
|
||
totals.our_margin_rub = parseFloat(res.totals.our_margin_rub);
|
||
adMarginPercent.value = res.ad_margin_percent;
|
||
} catch (e) {
|
||
fetchError.value = true;
|
||
errorMessage.value = extractErrorMessage(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadSpend);
|
||
onMounted(loadCpm);
|
||
watch(period, loadSpend);
|
||
|
||
const isEmpty = computed(() => !loading.value && !fetchError.value && rows.length === 0);
|
||
|
||
// === Кампании, ждущие номер креатива Яндекса (8e) ===
|
||
|
||
const awaitingCampaigns = reactive<AdminAwaitingCampaign[]>([]);
|
||
const awaitingLoading = ref(false);
|
||
const awaitingError = ref('');
|
||
/** Ввод «номер креатива» построчно, id кампании → строка из поля. */
|
||
const creativeInputs = reactive<Record<number, string>>({});
|
||
const creativeSaving = reactive<Record<number, boolean>>({});
|
||
const creativeSaved = reactive<Record<number, boolean>>({});
|
||
const creativeErrors = reactive<Record<number, string>>({});
|
||
|
||
const awaitingIsEmpty = computed(() => !awaitingLoading.value && awaitingCampaigns.length === 0);
|
||
|
||
async function loadAwaiting() {
|
||
awaitingLoading.value = true;
|
||
awaitingError.value = '';
|
||
try {
|
||
const list = await adminApi.fetchAdAwaitingCampaigns();
|
||
awaitingCampaigns.splice(0, awaitingCampaigns.length, ...list);
|
||
for (const c of list) {
|
||
creativeInputs[c.id] = c.yandex_creative_id !== null ? String(c.yandex_creative_id) : '';
|
||
}
|
||
} catch (e) {
|
||
awaitingError.value = extractErrorMessage(e);
|
||
} finally {
|
||
awaitingLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveCreative(campaignId: number) {
|
||
creativeSaving[campaignId] = true;
|
||
creativeSaved[campaignId] = false;
|
||
creativeErrors[campaignId] = '';
|
||
try {
|
||
const value = Number(creativeInputs[campaignId]);
|
||
const res = await adminApi.setAdCampaignCreative(campaignId, value);
|
||
const row = awaitingCampaigns.find((c) => c.id === campaignId);
|
||
if (row) row.yandex_creative_id = res.yandex_creative_id;
|
||
creativeSaved[campaignId] = true;
|
||
} catch (e) {
|
||
creativeErrors[campaignId] = extractErrorMessage(e);
|
||
} finally {
|
||
creativeSaving[campaignId] = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadAwaiting);
|
||
|
||
// === «Ждёт разбора»: сбойные задания робота (задача 15) ===
|
||
//
|
||
// 🔴 Это НЕ список обычных отказов модерации — их клиент разбирает сам, по причине
|
||
// в переписке. Это места, где ЦЕПОЧКА ВСТАЛА: робот сходил в кабинет и не понял, что
|
||
// видит, или не смог отвезти картинки. Без этого экрана обрыв тихий: клиент ждёт ответа,
|
||
// которого не будет, и человек об этом узнаёт только из письма — а письмо теряется.
|
||
|
||
const stuckJobs = reactive<AdminStuckRobotJob[]>([]);
|
||
const stuckLoading = ref(false);
|
||
const stuckError = ref('');
|
||
|
||
const stuckIsEmpty = computed(() => !stuckLoading.value && !stuckError.value && stuckJobs.length === 0);
|
||
|
||
const KIND_LABEL: Record<string, string> = {
|
||
upload: 'Отвезти картинки',
|
||
inspect: 'Узнать причину отказа',
|
||
deliver: 'Отвезти документ',
|
||
};
|
||
|
||
function kindLabel(kind: string): string {
|
||
return KIND_LABEL[kind] ?? kind;
|
||
}
|
||
|
||
async function loadStuck() {
|
||
stuckLoading.value = true;
|
||
stuckError.value = '';
|
||
try {
|
||
const list = await adminApi.fetchAdRobotStuck();
|
||
stuckJobs.splice(0, stuckJobs.length, ...list);
|
||
} catch (e) {
|
||
stuckError.value = extractErrorMessage(e);
|
||
} finally {
|
||
stuckLoading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadStuck);
|
||
|
||
defineExpose({
|
||
stuckJobs,
|
||
stuckLoading,
|
||
stuckError,
|
||
loadStuck,
|
||
rows,
|
||
totals,
|
||
adMarginPercent,
|
||
loading,
|
||
fetchError,
|
||
errorMessage,
|
||
period,
|
||
loadSpend,
|
||
cpm,
|
||
margin,
|
||
cpmSaving,
|
||
cpmSaved,
|
||
cpmError,
|
||
saveCpm,
|
||
loadCpm,
|
||
awaitingCampaigns,
|
||
awaitingLoading,
|
||
awaitingError,
|
||
creativeInputs,
|
||
creativeSaving,
|
||
creativeSaved,
|
||
creativeErrors,
|
||
loadAwaiting,
|
||
saveCreative,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="admin-advertising pa-6" data-testid="admin-advertising-view">
|
||
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-4">
|
||
<div>
|
||
<h1 class="text-h4 page-title">Реклама — расход и маржа</h1>
|
||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||
Сколько тенанты тратят на рекламу и сколько из этого остаётся нам маржой.
|
||
</p>
|
||
</div>
|
||
<v-btn
|
||
variant="outlined"
|
||
prepend-icon="mdi-refresh"
|
||
:loading="loading"
|
||
data-testid="reload-btn"
|
||
@click="loadSpend"
|
||
>
|
||
Обновить
|
||
</v-btn>
|
||
</header>
|
||
|
||
<v-card variant="outlined" class="pa-4 mb-4" data-testid="ad-cpm-card">
|
||
<div class="text-h6 mb-1">Цена клиенту за показы</div>
|
||
<p class="text-caption text-medium-emphasis mb-3">Сколько клиент платит за 1000 показов, ₽</p>
|
||
<div class="d-flex align-center flex-wrap ga-3">
|
||
<v-text-field
|
||
v-model="cpm"
|
||
type="number"
|
||
min="0"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details
|
||
style="max-width: 200px"
|
||
class="font-mono tabular"
|
||
data-testid="ad-cpm-input"
|
||
/>
|
||
<v-text-field
|
||
v-model="margin"
|
||
type="number"
|
||
min="0"
|
||
max="90"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
label="Наша наценка, %"
|
||
hint="В Директ уходит на столько меньше. Клиент не видит."
|
||
persistent-hint
|
||
style="max-width: 240px"
|
||
class="font-mono tabular"
|
||
data-testid="ad-margin-input"
|
||
/>
|
||
<v-btn
|
||
color="primary"
|
||
:loading="cpmSaving"
|
||
data-testid="ad-cpm-save"
|
||
@click="saveCpm"
|
||
>
|
||
Сохранить
|
||
</v-btn>
|
||
<span v-if="cpmSaved" class="text-success-strong text-body-2" data-testid="ad-cpm-saved">
|
||
Цена сохранена
|
||
</span>
|
||
<span v-if="cpmError" class="text-error text-body-2" data-testid="ad-cpm-error">
|
||
{{ cpmError }}
|
||
</span>
|
||
</div>
|
||
</v-card>
|
||
|
||
<v-btn-toggle
|
||
v-model="period"
|
||
mandatory
|
||
density="comfortable"
|
||
color="primary"
|
||
variant="outlined"
|
||
class="mb-4"
|
||
data-testid="ad-spend-period"
|
||
>
|
||
<v-btn
|
||
v-for="opt in periodOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
:data-testid="`period-${opt.value}`"
|
||
>
|
||
{{ opt.label }}
|
||
</v-btn>
|
||
</v-btn-toggle>
|
||
|
||
<v-alert
|
||
v-if="fetchError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
closable
|
||
class="mb-4"
|
||
data-testid="fetch-error-alert"
|
||
>
|
||
{{ errorMessage || 'Не удалось загрузить расход на рекламу. Попробуйте обновить.' }}
|
||
</v-alert>
|
||
|
||
<!-- ИТОГО-строка -->
|
||
<v-row class="mb-4" data-testid="ad-spend-totals">
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">Расход клиентов</div>
|
||
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-client">
|
||
{{ formatPlain(totals.client_spend_rub) }}
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">Расход у Яндекса</div>
|
||
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-yandex">
|
||
{{ formatPlain(totals.yandex_cost_rub) }}
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">Наша маржа</div>
|
||
<div class="text-h6 font-mono tabular text-success-strong" data-testid="ad-spend-total-margin">
|
||
{{ formatPlain(totals.our_margin_rub) }}
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<v-card variant="outlined" class="pa-4">
|
||
<div class="d-flex justify-space-between align-center mb-3">
|
||
<h2 class="text-h6 ma-0">По тенантам</h2>
|
||
<span class="text-caption text-medium-emphasis" data-testid="ad-spend-markup">
|
||
Наценка: {{ adMarginPercent }}%
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="loading" class="d-flex justify-center pa-8">
|
||
<v-progress-circular indeterminate color="primary" data-testid="ad-spend-loading" />
|
||
</div>
|
||
|
||
<p v-else-if="isEmpty" class="text-body-2 text-medium-emphasis ma-0 pa-4" data-testid="ad-spend-empty">
|
||
За период рекламных расходов нет.
|
||
</p>
|
||
|
||
<v-table v-else density="comfortable" class="font-mono-nums">
|
||
<thead>
|
||
<tr>
|
||
<th>Тенант</th>
|
||
<th class="text-right">Расход клиента</th>
|
||
<th class="text-right">Расход Яндекса</th>
|
||
<th class="text-right">Наша маржа</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in rows" :key="row.tenant_id" data-testid="ad-spend-row">
|
||
<td class="font-weight-medium">{{ row.tenant_name }}</td>
|
||
<td class="text-right font-mono tabular">{{ formatPlain(row.client_spend_rub) }}</td>
|
||
<td class="text-right font-mono tabular">{{ formatPlain(row.yandex_cost_rub) }}</td>
|
||
<td class="text-right font-mono tabular text-success-strong">
|
||
{{ formatPlain(row.our_margin_rub) }}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card>
|
||
|
||
<v-card variant="outlined" class="pa-4 mt-4" data-testid="ad-awaiting-card">
|
||
<div class="text-h6 mb-1">Кампании на запуск — номер креатива</div>
|
||
<p class="text-caption text-medium-emphasis mb-3">
|
||
Оформите креатив в конструкторе Яндекса и впишите его номер, чтобы кампанию можно было запустить.
|
||
</p>
|
||
|
||
<v-alert
|
||
v-if="awaitingError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
closable
|
||
class="mb-4"
|
||
data-testid="ad-awaiting-fetch-error"
|
||
>
|
||
{{ awaitingError }}
|
||
</v-alert>
|
||
|
||
<div v-if="awaitingLoading" class="d-flex justify-center pa-8">
|
||
<v-progress-circular indeterminate color="primary" data-testid="ad-awaiting-loading" />
|
||
</div>
|
||
|
||
<p
|
||
v-else-if="awaitingIsEmpty"
|
||
class="text-body-2 text-medium-emphasis ma-0 pa-4"
|
||
data-testid="ad-awaiting-empty"
|
||
>
|
||
Кампаний, ждущих номер креатива, нет.
|
||
</p>
|
||
|
||
<v-table v-else density="comfortable">
|
||
<thead>
|
||
<tr>
|
||
<th>Тенант</th>
|
||
<th>Кампания</th>
|
||
<th class="text-right">Смета показов</th>
|
||
<th>Ссылка</th>
|
||
<th>Номер креатива</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in awaitingCampaigns" :key="c.id" data-testid="ad-awaiting-row">
|
||
<td class="font-weight-medium">{{ c.tenant_name }}</td>
|
||
<td>{{ c.name }}</td>
|
||
<td class="text-right font-mono tabular">
|
||
{{ c.estimated_impressions !== null ? c.estimated_impressions : '—' }}
|
||
</td>
|
||
<td>
|
||
<a v-if="c.landing_url" :href="c.landing_url" target="_blank" rel="noopener">{{
|
||
c.landing_url
|
||
}}</a>
|
||
<span v-else>—</span>
|
||
</td>
|
||
<td>
|
||
<v-text-field
|
||
v-model="creativeInputs[c.id]"
|
||
type="number"
|
||
min="1"
|
||
density="comfortable"
|
||
variant="outlined"
|
||
hide-details
|
||
style="max-width: 200px"
|
||
class="font-mono tabular"
|
||
data-testid="ad-creative-input"
|
||
/>
|
||
</td>
|
||
<td>
|
||
<v-btn
|
||
color="primary"
|
||
size="small"
|
||
:loading="creativeSaving[c.id]"
|
||
data-testid="ad-creative-save"
|
||
@click="saveCreative(c.id)"
|
||
>
|
||
Сохранить
|
||
</v-btn>
|
||
<div
|
||
v-if="creativeSaved[c.id]"
|
||
class="text-success-strong text-body-2"
|
||
data-testid="ad-creative-saved"
|
||
>
|
||
Сохранено
|
||
</div>
|
||
<div
|
||
v-if="creativeErrors[c.id]"
|
||
class="text-error text-body-2"
|
||
data-testid="ad-creative-error"
|
||
>
|
||
{{ creativeErrors[c.id] }}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card>
|
||
|
||
<!--
|
||
«Ждёт разбора» — робот сходил в кабинет Яндекса и не понял, что видит.
|
||
Не обычные отказы: их клиент разбирает сам по причине в переписке.
|
||
-->
|
||
<v-card class="mt-6 pa-4" data-testid="ad-stuck-card">
|
||
<div class="d-flex align-center justify-space-between mb-2">
|
||
<h2 class="text-h6 ma-0">Ждёт разбора</h2>
|
||
<v-btn variant="text" size="small" :loading="stuckLoading" data-testid="ad-stuck-refresh" @click="loadStuck">
|
||
Обновить
|
||
</v-btn>
|
||
</div>
|
||
|
||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||
Здесь только сбои робота: он сходил в кабинет и не разобрался, что там написано,
|
||
либо не смог отвезти картинки. Обычные отказы модерации сюда не попадают —
|
||
их клиент разбирает сам, причина лежит у него в переписке по кампании.
|
||
</p>
|
||
|
||
<v-alert v-if="stuckError" type="error" variant="tonal" class="mb-4" data-testid="ad-stuck-error">
|
||
{{ stuckError }}
|
||
</v-alert>
|
||
|
||
<div v-if="stuckLoading" class="d-flex justify-center pa-8">
|
||
<v-progress-circular indeterminate color="primary" data-testid="ad-stuck-loading" />
|
||
</div>
|
||
|
||
<p v-else-if="stuckIsEmpty" class="text-body-2 text-medium-emphasis ma-0 pa-4" data-testid="ad-stuck-empty">
|
||
Разбирать нечего — робот со всеми заданиями справился.
|
||
</p>
|
||
|
||
<v-table v-else density="comfortable">
|
||
<thead>
|
||
<tr>
|
||
<th>Клиент</th>
|
||
<th>Кампания</th>
|
||
<th>Что делал робот</th>
|
||
<th>Объявление в Яндексе</th>
|
||
<th>На чём споткнулся</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="j in stuckJobs" :key="j.id" data-testid="ad-stuck-row">
|
||
<td class="font-weight-medium">{{ j.tenant_name }}</td>
|
||
<td>{{ j.campaign_name }}</td>
|
||
<td>{{ kindLabel(j.kind) }}</td>
|
||
<td class="font-mono tabular">{{ j.yandex_ad_id !== null ? j.yandex_ad_id : '—' }}</td>
|
||
<td>{{ j.failure_reason || 'Причина не записана' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</v-table>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-advertising {
|
||
max-width: 1400px;
|
||
}
|
||
.page-title {
|
||
font-variation-settings: 'opsz' 28;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
}
|
||
.tabular {
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
</style>
|