Files
portal/app/resources/js/views/admin/AdminSystemView.vue
T
Дмитрий 1fef2571e8
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(y360): баланс почты Яндекс 360 — ручной ввод + кнопка Пополнить
email — денежный сервис; сумма вписывается в админке «Система» (Yandex360BalanceStore),
светофор по порогам, кнопка «Открыть оплату»/«Пополнить» → admin.yandex.ru/products.
Робот-скрейпер отклонён (SPA Яндекса враждебен ботам + автопополнение защищает баланс).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:34:13 +03:00

293 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Админка SaaS → Система.
*
* Глобальные настройки SaaS-уровня (system_settings по schema v8.7 §10):
* лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.
*
* Display + edit-режим. Данные с backend GET /api/admin/system-settings.
*/
import type { AdminSystemSetting } from '../../composables/mockAdmin';
import * as adminApi from '../../api/admin';
import type { SystemSetting as ApiSystemSetting } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { computed, onMounted, reactive, ref } from 'vue';
import SystemSettingEditDialog from '../../components/admin/SystemSettingEditDialog.vue';
const search = ref('');
const loading = ref(false);
const fetchError = ref<string | null>(null);
/**
* Settings-state. Наполняется на mount через `adminApi.listSystemSettings()`.
* До загрузки и при ошибке — пустой; ошибка показывается через fetchError-banner.
*/
const settingsState = reactive<AdminSystemSetting[]>([]);
async function loadSettings() {
loading.value = true;
fetchError.value = null;
try {
const fromApi = await adminApi.listSystemSettings();
// Replace всё содержимое сохранив reactive-ref.
settingsState.splice(0, settingsState.length, ...(fromApi as unknown as AdminSystemSetting[]));
} catch (err) {
// На fail — settingsState пустой, показываем error-banner.
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить настройки с сервера. Попробуйте обновить.');
} finally {
loading.value = false;
}
}
onMounted(() => {
loadSettings();
});
const filteredSettings = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return settingsState;
return settingsState.filter((s) => s.key.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
});
const typeColor: Record<AdminSystemSetting['type'], string> = {
int: 'info',
string: 'success',
bool: 'warning',
json: 'secondary',
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
const editOpen = ref(false);
const editSetting = ref<ApiSystemSetting | null>(null);
const ADMIN_USER_ID = 1;
function openEdit(s: AdminSystemSetting) {
editSetting.value = {
key: s.key,
value: s.value,
type: s.type as ApiSystemSetting['type'],
description: s.description,
updated_at: s.updated_at,
updated_by: null,
};
editOpen.value = true;
}
function onSettingUpdated(payload: { key: string; value: string; updated_at: string }) {
const target = settingsState.find((s) => s.key === payload.key);
if (target) {
target.value = payload.value;
target.updated_at = payload.updated_at;
}
}
// ─── Ручной баланс Яндекс 360 (почта) ───────────────────────────────────────
const y360Input = ref<string>('');
const y360Status = ref<adminApi.Yandex360BalanceStatus>({ balance: null, updated_at: null });
const y360Saving = ref(false);
const y360Error = ref<string | null>(null);
const Y360_TOPUP_URL = 'https://admin.yandex.ru/products';
async function loadY360Status() {
try {
y360Status.value = await adminApi.getYandex360Balance();
y360Input.value = y360Status.value.balance !== null ? String(y360Status.value.balance) : '';
} catch {
// тихо: статус не критичен для экрана
}
}
async function saveY360Balance() {
y360Saving.value = true;
y360Error.value = null;
try {
const val = y360Input.value.trim() === '' ? null : Number(y360Input.value.replace(',', '.'));
y360Status.value = await adminApi.setYandex360Balance(val);
} catch (err) {
y360Error.value = extractErrorMessage(err, 'Не удалось сохранить баланс.');
} finally {
y360Saving.value = false;
}
}
onMounted(() => {
loadY360Status();
});
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError, y360Input, y360Status, saveY360Balance });
</script>
<template>
<v-container fluid class="admin-system pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 page-title">Система</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Глобальные настройки SaaS: лимиты квот, тарифные планы, фичефлаги.
</p>
</header>
<v-alert type="info" variant="tonal" class="mb-4" density="compact">
Edit-flow требует основания 30 символов и двухстадийного подтверждения; результат записывается в
<code>saas_admin_audit_log</code> с hash-chain (OPEN-И-15).
</v-alert>
<v-card variant="outlined" class="mb-4" data-testid="y360-balance-card">
<v-card-title class="text-subtitle-1"> Баланс почты (Яндекс 360)</v-card-title>
<v-card-text>
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
У Яндекс 360 нет автоматического способа узнать баланс, поэтому впишите сумму вручную
она из кабинета Яндекс 360 «Оплата и тариф» (строка «Баланс»). Обновляйте по мере надобности;
на плитке она покажется со светофором. Кнопка «Открыть оплату» ведёт прямо в кабинет.
</v-alert>
<div class="mb-2 text-body-2">
Текущий баланс:
<strong v-if="y360Status.balance !== null" class="text-success">{{ y360Status.balance }} </strong>
<strong v-else class="text-medium-emphasis">не задан</strong>
<span v-if="y360Status.updated_at" class="text-medium-emphasis">
· обновлён {{ formatDate(y360Status.updated_at) }}</span>
</div>
<v-alert v-if="y360Error" type="warning" variant="tonal" density="compact" class="mb-2">
{{ y360Error }}
</v-alert>
<div class="d-flex align-center ga-3 flex-wrap">
<v-text-field
v-model="y360Input"
label="Баланс, ₽"
type="number"
density="compact"
hide-details
style="max-width: 200px"
/>
<v-btn color="primary" :loading="y360Saving" @click="saveY360Balance">Сохранить</v-btn>
<v-btn variant="text" :href="Y360_TOPUP_URL" target="_blank" prepend-icon="mdi-open-in-new">
Открыть оплату
</v-btn>
</div>
</v-card-text>
</v-card>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
class="mb-4"
density="compact"
data-testid="fetch-error-alert"
closable
@click:close="fetchError = null"
>
{{ fetchError }}
</v-alert>
<v-card variant="outlined" class="pa-4">
<div class="d-flex justify-space-between align-center mb-3 ga-2 flex-wrap">
<h2 class="text-h6 ma-0">system_settings</h2>
<v-spacer />
<v-text-field
v-model="search"
label="Поиск"
placeholder="по ключу или описанию"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
style="max-width: 320px"
/>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadSettings"
>
Обновить
</v-btn>
</div>
<v-list class="settings-list">
<v-list-item
v-for="setting in filteredSettings"
:key="setting.key"
class="setting-row"
data-testid="setting-row"
>
<div class="setting-header">
<span class="setting-key font-mono">{{ setting.key }}</span>
<v-chip :color="typeColor[setting.type]" size="x-small" variant="tonal" class="ml-2">
{{ setting.type }}
</v-chip>
<v-spacer />
<v-btn
variant="text"
size="small"
density="comfortable"
prepend-icon="mdi-pencil"
:aria-label="`Изменить настройку ${setting.key}`"
:data-testid="`edit-${setting.key}-btn`"
@click="openEdit(setting)"
>
Изменить
</v-btn>
</div>
<div class="setting-value font-mono mt-1">{{ setting.value }}</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ setting.description }} · обновлено {{ formatDate(setting.updated_at) }}
</div>
</v-list-item>
</v-list>
</v-card>
<SystemSettingEditDialog
v-model="editOpen"
:setting="editSetting"
:requested-by="ADMIN_USER_ID"
@updated="onSettingUpdated"
/>
</v-container>
</template>
<style scoped>
.admin-system {
max-width: 1100px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.setting-row {
padding-block: 12px;
border-bottom: 1px solid #e1eeea;
}
.setting-row:last-child {
border-bottom: none;
}
.setting-header {
display: flex;
align-items: center;
}
.setting-key {
font-weight: 500;
font-size: 14px;
color: #081319;
}
.setting-value {
font-size: 13px;
background: #f6f3ec;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
</style>