feat(реклама): кнопка «Запустить» у клиента на карточке кампании
Кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком, ни расписанием. Клиент собирал рекламу, заливал пятнадцать картинок, отправлял заявку и упирался в тупик: на карточке только «Изменить» и «Отчёт». Запуск был возможен лишь обращением, к которому в кабинете нет кнопки. Поймано приёмкой 03.08.2026 на кампании #11. Новых дыр кнопка не открывает: путь запуска и так лежал в клиентской группе, все проверки — деньги, минимум площадки, годность аудитории — внутри самого запуска. Три ответа портала показываются по-разному: «готовим картинки» и «аудитория ещё готовится» — спокойным синим сообщением, это нормальный ход, деньги не тронуты и в кабинете ничего не создано; смета мала, не хватает денег, Яндекс молчит — красным. Денежная плашка перечитывается только после удачного запуска. Отдельного «вы уверены?» намеренно нет: кнопка подписана, смета показов стоит тут же на карточке, пауза возвращает заморозку целиком — проверено живьём 03.08 на кампании #6. Вместо окна под кнопкой строчка «При запуске 1 020,60 руб. будут зарезервированы на кошельке»: клиент знает про деньги до нажатия, а не узнаёт потом из кошелька. Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у черновика; удачный запуск перечитывает список и денежную плашку; «аудитория ещё готовится» даёт спокойное сообщение и не тревожит плашку; отказ даёт красное; под кнопкой сказано про резерв сметы. Прогон: 2049 сторожей фронта зелёные, типы Vue чисты, стиль чист. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -295,10 +295,23 @@ export async function approveBanners(id: number): Promise<{ approved_at: string
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответы «зайдите позже»: портал отвечает ими, когда всё в порядке — картинки ещё грузятся
|
||||
* в рекламный кабинет либо рекламная система ещё сводит список номеров с людьми. Это НЕ
|
||||
* отказы: кампания остаётся как была, деньги не тронуты, в кабинете ничего не создано.
|
||||
*/
|
||||
export type LaunchPendingStatus = 'creatives_pending' | 'audience_pending';
|
||||
|
||||
export type LaunchResult = {
|
||||
status: CampaignStatus | LaunchPendingStatus;
|
||||
/** Человеческое пояснение — есть только у ответов «зайдите позже». */
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */
|
||||
export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> {
|
||||
export async function launchCampaign(id: number): Promise<LaunchResult> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`);
|
||||
const { data } = await apiClient.post<LaunchResult>(`/api/advertising/campaigns/${id}/launch`);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resumeCampaign,
|
||||
deleteCampaign,
|
||||
reviveCampaign,
|
||||
launchCampaign,
|
||||
type Campaign,
|
||||
type CampaignStatus,
|
||||
} from '../../api/advertising';
|
||||
@@ -50,6 +51,14 @@ const loading = ref(true);
|
||||
const loadError = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
|
||||
/**
|
||||
* Спокойное сообщение «зайдите позже» — отдельно от ошибок. Портал отвечает им на запуск,
|
||||
* когда всё в порядке: картинки ещё грузятся в рекламный кабинет либо рекламная система
|
||||
* ещё сводит список номеров с людьми (на бою 30.07.2026 — около 15 часов). Красным такое
|
||||
* показывать нельзя: клиент решит, что сделал что-то не так, и начнёт переделывать готовое.
|
||||
*/
|
||||
const actionNote = ref<string | null>(null);
|
||||
|
||||
// id кампании, для которой сейчас идёт запрос паузы/возобновления — управляет :loading кнопки.
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
@@ -137,6 +146,52 @@ async function onEdit(campaign: Campaign): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* «Запустить» — у кампании, готовой к запуску.
|
||||
*
|
||||
* Приёмка 03.08.2026: клиент собирал рекламу, заливал пятнадцать картинок, отправлял
|
||||
* заявку — и упирался в тупик. Кампания вставала «Готова к запуску», а на карточке были
|
||||
* только «Изменить» и «Отчёт»: запустить её можно было лишь обращением, к которому в
|
||||
* кабинете нет кнопки. Модуль клиенту не работал вообще.
|
||||
*
|
||||
* Новых дыр кнопка не открывает: путь запуска и так лежит в клиентской группе, а все
|
||||
* проверки — деньги, минимум площадки, готовность аудитории — живут внутри самого запуска.
|
||||
*/
|
||||
function canLaunch(status: CampaignStatus): boolean {
|
||||
return status === 'queued';
|
||||
}
|
||||
|
||||
/**
|
||||
* Три ответа портала на запуск клиент должен видеть по-разному:
|
||||
* — «готовим картинки» и «аудитория ещё готовится» — спокойным сообщением: это не отказ,
|
||||
* деньги не тронуты, надо просто зайти позже;
|
||||
* — всё остальное (смета мала, не хватает денег, Яндекс не отвечает) — красным;
|
||||
* — удачный запуск запирает смету, поэтому денежная плашка обязана перечитаться.
|
||||
*/
|
||||
async function onLaunch(campaign: Campaign): Promise<void> {
|
||||
busyId.value = campaign.id;
|
||||
actionError.value = null;
|
||||
actionNote.value = null;
|
||||
try {
|
||||
const res = await launchCampaign(campaign.id);
|
||||
const podozhdite = res.status === 'creatives_pending' || res.status === 'audience_pending';
|
||||
|
||||
if (podozhdite) {
|
||||
actionNote.value = res.message ?? 'Пока рано — зайдите позже и нажмите «Запустить» ещё раз.';
|
||||
}
|
||||
|
||||
await loadCampaigns();
|
||||
|
||||
if (!podozhdite) {
|
||||
emit('money-moved'); // запуск запер смету
|
||||
}
|
||||
} catch (e) {
|
||||
actionError.value = extractErrorMessage(e, 'Не удалось запустить кампанию.');
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Статусы, из которых можно поставить на паузу. */
|
||||
function canPause(status: CampaignStatus): boolean {
|
||||
return status === 'running' || status === 'pending_moderation';
|
||||
@@ -259,6 +314,20 @@ defineExpose({ loadCampaigns });
|
||||
{{ actionError }}
|
||||
</v-alert>
|
||||
|
||||
<!-- «Зайдите позже» — не отказ: синим, спокойно, без слова «ошибка». -->
|
||||
<v-alert
|
||||
v-if="actionNote"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
closable
|
||||
data-testid="campaign-action-note"
|
||||
@click:close="actionNote = null"
|
||||
>
|
||||
{{ actionNote }}
|
||||
</v-alert>
|
||||
|
||||
<v-row dense>
|
||||
<v-col v-for="campaign in campaigns" :key="campaign.id" cols="12" sm="6" lg="4">
|
||||
<v-card variant="outlined" class="pa-4 h-100" data-testid="campaign-card">
|
||||
@@ -344,6 +413,19 @@ defineExpose({ loadCampaigns });
|
||||
{{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="canLaunch(campaign.status)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
prepend-icon="mdi-rocket-launch-outline"
|
||||
:loading="busyId === campaign.id"
|
||||
data-testid="campaign-launch-btn"
|
||||
@click="onLaunch(campaign)"
|
||||
>
|
||||
Запустить
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="canPause(campaign.status)"
|
||||
size="small"
|
||||
@@ -393,6 +475,24 @@ defineExpose({ loadCampaigns });
|
||||
Удалить
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Запуск запирает смету на кошельке. Клиент должен знать это ДО
|
||||
нажатия, а не узнать потом из кошелька. Отдельного «вы уверены?»
|
||||
не делаем: кнопка подписана, смета стоит тут же на карточке,
|
||||
а пауза возвращает заморозку целиком (проверено 03.08.2026).
|
||||
-->
|
||||
<div
|
||||
v-if="canLaunch(campaign.status)"
|
||||
class="text-caption text-medium-emphasis mt-2"
|
||||
data-testid="campaign-launch-hint"
|
||||
>
|
||||
{{
|
||||
campaign.smeta_rub
|
||||
? `При запуске ${formatMoney(campaign.smeta_rub)} будут зарезервированы на кошельке.`
|
||||
: 'При запуске смета будет зарезервирована на кошельке.'
|
||||
}}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
fetchCampaignMock,
|
||||
deleteCampaignMock,
|
||||
reviveCampaignMock,
|
||||
launchCampaignMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchCampaignsMock: vi.fn(),
|
||||
pauseCampaignMock: vi.fn(),
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
fetchCampaignMock: vi.fn(),
|
||||
deleteCampaignMock: vi.fn(),
|
||||
reviveCampaignMock: vi.fn(),
|
||||
launchCampaignMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../resources/js/api/advertising', () => ({
|
||||
@@ -25,6 +27,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
|
||||
fetchCampaign: fetchCampaignMock,
|
||||
deleteCampaign: deleteCampaignMock,
|
||||
reviveCampaign: reviveCampaignMock,
|
||||
launchCampaign: launchCampaignMock,
|
||||
}));
|
||||
|
||||
import CampaignList from '../../resources/js/components/advertising/CampaignList.vue';
|
||||
@@ -54,6 +57,7 @@ describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)
|
||||
resumeCampaignMock.mockReset();
|
||||
fetchCampaignMock.mockReset();
|
||||
deleteCampaignMock.mockReset();
|
||||
launchCampaignMock.mockReset();
|
||||
fetchCampaignMock.mockResolvedValue({
|
||||
campaign: {
|
||||
id: 1,
|
||||
@@ -463,4 +467,118 @@ describe('CampaignList.vue — кнопка «Исправить» оживля
|
||||
expect(w.emitted('edit')).toBeUndefined();
|
||||
expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Приёмка 03.08.2026: клиент собрал рекламу, залил пятнадцать картинок, отправил заявку —
|
||||
* и упёрся в тупик. Кампания встала «Готова к запуску», а на карточке только «Изменить»
|
||||
* и «Отчёт». Запустить её можно было лишь обращением, к которому в кабинете нет кнопки:
|
||||
* модуль клиенту не работал вообще.
|
||||
*/
|
||||
const gotovaKZapusku = [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Реклама от 03.08.2026',
|
||||
status: 'queued',
|
||||
audience_days: 30,
|
||||
launched_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('у кампании «Готова к запуску» есть кнопка «Запустить», и она запускает', async () => {
|
||||
fetchCampaignsMock.mockResolvedValue(gotovaKZapusku);
|
||||
launchCampaignMock.mockResolvedValue({ status: 'pending_moderation' });
|
||||
|
||||
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const btn = w.find('[data-testid="campaign-launch-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
|
||||
await btn.trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(launchCampaignMock).toHaveBeenCalledWith(11);
|
||||
expect(fetchCampaignsMock).toHaveBeenCalledTimes(2); // список перечитан
|
||||
// Удачный запуск запирает смету — денежная плашка обязана перечитаться.
|
||||
expect(w.emitted('money-moved')).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* «Аудитория ещё готовится» и «готовим картинки» — НЕ отказы: портал отвечает ими,
|
||||
* когда всё в порядке и надо просто зайти позже. Деньги при этом не тронуты и
|
||||
* в рекламном кабинете ничего не создано. Показать их красным — напугать клиента
|
||||
* и заставить переделывать то, что уже сделано верно.
|
||||
*/
|
||||
it('«аудитория ещё готовится» показывается спокойным сообщением, а не ошибкой', async () => {
|
||||
fetchCampaignsMock.mockResolvedValue(gotovaKZapusku);
|
||||
launchCampaignMock.mockResolvedValue({
|
||||
status: 'audience_pending',
|
||||
message: 'Аудитория ещё готовится — зайдите позже и нажмите «Запустить» ещё раз.',
|
||||
});
|
||||
|
||||
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
await w.find('[data-testid="campaign-launch-btn"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const note = w.find('[data-testid="campaign-action-note"]');
|
||||
expect(note.exists()).toBe(true);
|
||||
expect(note.text()).toContain('Аудитория ещё готовится');
|
||||
expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(false);
|
||||
// Ничего не запиралось — тревожить денежную плашку незачем.
|
||||
expect(w.emitted('money-moved')).toBeFalsy();
|
||||
});
|
||||
|
||||
/** Настоящий отказ — красным и словами сервера: «смета мала», «не хватает денег». */
|
||||
it('отказ запуска показывается ошибкой', async () => {
|
||||
fetchCampaignsMock.mockResolvedValue(gotovaKZapusku);
|
||||
launchCampaignMock.mockRejectedValue(new Error('смета мала'));
|
||||
|
||||
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
await w.find('[data-testid="campaign-launch-btn"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="campaign-action-note"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Запуск запирает деньги — клиент должен знать это ДО нажатия, а не узнать из кошелька.
|
||||
* Отдельного «вы уверены?» не делаем: кнопка подписана, смета показов стоит тут же на
|
||||
* карточке, а пауза возвращает заморозку целиком (проверено живьём 03.08.2026).
|
||||
*/
|
||||
it('под кнопкой «Запустить» сказано, что смета будет зарезервирована', async () => {
|
||||
fetchCampaignsMock.mockResolvedValue([{ ...gotovaKZapusku[0], smeta_rub: '1020.60' }]);
|
||||
|
||||
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const hint = w.find('[data-testid="campaign-launch-hint"]');
|
||||
expect(hint.exists()).toBe(true);
|
||||
expect(hint.text()).toContain('зарезерв');
|
||||
});
|
||||
|
||||
/** Кнопка ровно там, где ей место: уже работающую кампанию запускать нечего. */
|
||||
it('у работающей кампании и у черновика кнопки «Запустить» нет', async () => {
|
||||
fetchCampaignsMock.mockResolvedValue([
|
||||
{ id: 1, name: 'Крутится', status: 'running', audience_days: 30, launched_at: null },
|
||||
{ id: 2, name: 'Черновик', status: 'draft', audience_days: 30, launched_at: null },
|
||||
]);
|
||||
|
||||
const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.findAll('[data-testid="campaign-launch-btn"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@ Windows показывает верное настенное время (18:26
|
||||
| # | Что | Замер |
|
||||
|---|---|---|
|
||||
| 1 | ✅ **ПОЧИНЕНО 04.08.** «Показывать 1 день» превращалось в период ДВУХ дней: `resolvePeriod` ставил конец «сегодня + `run_days`», а Яндекс считает оба края. Минимум площадки удваивался: вместо 300 ₽ требовал 600 ₽. Разбор — §6г. | смета 612,36 ₽ отклонена, потребовано 833,40 ₽ |
|
||||
| 2 | 🔴 **У клиента НЕТ кнопки «Запустить».** После отправки заявки кампания висит «Готова к запуску» и ждёт оператора; на карточке только «Изменить» и «Отчёт». Запуск возможен лишь обращением, к которому в кабинете нет кнопки. | карточка #11: кнопки «ИЗМЕНИТЬ, ОТЧЁТ» |
|
||||
| 2 | ✅ **ПОЧИНЕНО 04.08.** У клиента НЕ было кнопки «Запустить»: после отправки заявки кампания висела «Готова к запуску», а на карточке только «Изменить» и «Отчёт». Разбор — §6д. | карточка #11: кнопки «ИЗМЕНИТЬ, ОТЧЁТ» |
|
||||
| 3 | **Про минимум узнаёшь только на запуске** — мастер даёт спокойно собрать кампанию за 612 ₽, залить 15 картинок и лишь потом отказывает. Предупреждения на шаге сметы нет. | шаг 2 показывает «Итого 612,36 ₽» без единого слова |
|
||||
| 4 | **Счётчик аудитории не пересчитывается после загрузки списка.** Загрузили 1693 номера — под кнопкой по-прежнему «Наберётся ~15 человек» и красное «аудитория меньше 100». Правда появляется, только если тронуть любое другое поле. Клиент решит, что список не принялся. | было ~15, стало ~1701 после правки даты |
|
||||
| 5 | **Красная подпись про неверный размер остаётся** на карточке баннера и после того, как в неё загружен верный файл. Уходит только перезаливкой. | «Нужен ровно 160×600» висела при живой картинке 160×600 |
|
||||
@@ -460,6 +460,42 @@ Laravel («ожидаемого запроса не было») не говор
|
||||
|
||||
---
|
||||
|
||||
## 6д. ✅ Кнопка «Запустить» у клиента — сделано 04.08
|
||||
|
||||
**Было:** кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком, ни
|
||||
расписанием. Клиент собирал рекламу, заливал пятнадцать картинок, отправлял заявку и упирался
|
||||
в тупик: на карточке только «Изменить» и «Отчёт». Модуль ему не работал вообще.
|
||||
|
||||
**Стало:** на карточке «Готова к запуску» появилась кнопка **«Запустить»**.
|
||||
|
||||
🪤 **Новых дыр она не открывает:** путь запуска и так лежал в клиентской группе, а все
|
||||
проверки — деньги, минимум площадки, годность аудитории — живут внутри самого запуска.
|
||||
Кнопка лишь даёт клиенту нажать то, что до сих пор жали из консоли браузера.
|
||||
|
||||
**Три ответа портала показываются по-разному** — иначе кнопка пугала бы:
|
||||
|
||||
| Ответ портала | Что видит клиент |
|
||||
|---|---|
|
||||
| «готовим картинки в кабинете» | спокойное синее сообщение: зайдите позже |
|
||||
| «аудитория ещё готовится» | то же — это **нормальный ход**, а не отказ |
|
||||
| смета мала / не хватает денег / Яндекс молчит | красным, словами сервера |
|
||||
|
||||
Разница не косметическая: на «зайдите позже» деньги **не тронуты** и в рекламном кабинете
|
||||
ничего не создано. Покажи это красным — клиент решит, что ошибся, и начнёт переделывать
|
||||
готовое. Поэтому же денежная плашка перечитывается только после **удачного** запуска.
|
||||
|
||||
**Отдельного «вы уверены?» намеренно нет.** Кнопка подписана, смета показов стоит тут же на
|
||||
карточке, а пауза возвращает заморозку целиком — это проверено живьём 03.08 на кампании #6.
|
||||
Вместо окна под кнопкой стоит строчка: «При запуске 1 020,60 ₽ будут зарезервированы на
|
||||
кошельке» — клиент знает про деньги **до** нажатия, а не узнаёт потом из кошелька.
|
||||
|
||||
Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у черновика; удачный
|
||||
запуск перечитывает список и денежную плашку; «аудитория ещё готовится» даёт спокойное
|
||||
сообщение и **не** тревожит плашку; отказ даёт красное. Прогон: **2049 сторожей фронта
|
||||
зелёные** (плюс 3 отложенных), типы Vue чисты, стиль чист.
|
||||
|
||||
---
|
||||
|
||||
## 7. Что делать дальше
|
||||
|
||||
1. ✅ **Сделано 03.08 вечером** — денежная плашка, летопись, история на экране кошелька и
|
||||
@@ -472,12 +508,14 @@ Laravel («ожидаемого запроса не было») не говор
|
||||
🪤 Считать заранее: Яндекс не берёт кампанию дешевле **300 ₽ за каждый день** периода.
|
||||
На один день это примерно **417 ₽** клиентских — в потолок 1000 ₽ помещается, на неделю
|
||||
уже нет.
|
||||
4. **Выкатить на боевой** починку порядка денег и вчерашнюю починку кошелька — обе только
|
||||
в ветке.
|
||||
4. **Выкатить на боевой** починку порядка денег, вчерашнюю починку кошелька и правки 04.08
|
||||
(лишний день, кнопка «Запустить», счётчик аудитории, склейка ленты) — всё это только
|
||||
в ветке. 🪤 Кнопка «Запустить» появится у клиента **только после выката**.
|
||||
5. Кампанию #6 на 06.08 должно закрыть по сроку с возвратом заморозки — **проверить 07.08**,
|
||||
это бесплатный замер второй половины Я-Д5 (выход «по сроку», а не «по паузе»).
|
||||
6. **Завтра, 04.08: третий заход «Запустить» у кампании #11.** Сведение сегмента 58389879
|
||||
началось 03.08 около 18:44 МСК, Яндексу нужно ~15 часов — значит после ~10:00 МСК.
|
||||
Этот заход заморозит 1 020,60 ₽ и заведёт кампанию в Директе. Дальше по ранбуку:
|
||||
Я-Д1 живьём → Я-М2 (одобрение) → Я-Д3/Д4/Д6 (списание по факту показов).
|
||||
🪤 Кнопки «Запустить» у клиента нет — жать придётся тем же путём, что и вечером 03.08.
|
||||
🪤 Кнопка «Запустить» починена 04.08, но **на боевой не выкачена** — жать придётся тем же
|
||||
путём, что и вечером 03.08, обращением из консоли браузера.
|
||||
|
||||
Reference in New Issue
Block a user