Files
portal/app/resources/js/views/admin/AdminSystemView.vue
T
Дмитрий 929bf9f45b fix интерфейс: время везде показывается по Москве, а не по часам браузера
Считать портал уже научился по московским суткам. Показывал он вразнобой:
часть страниц просила браузер явно про Москву, двадцать мест оставляли пояс
на его усмотрение. Человек за пределами Москвы видел у заявки не тот день,
что в отчёте, — при том что фильтры и отчёты считают московские сутки.

Двадцать мест поправлены: заявки, счета, проводки, сессии и смена пароля,
происшествия, запросы субъектов ПДн, ступени цен, интеграция с поставщиком,
проекты поставщика, система, посетители, вход под клиентом, кампании,
поле автоподбора, СМС и прогрев у отдела продаж, карточка клиента.

Одно место оставлено гринвичским НАМЕРЕННО и с объяснением рядом:
firstLeadDate собирает дату из уже московских чисел через Date.UTC, и
повторный перевод сдвинул бы день назад.

Сторож tools/storozh-moskovskogo-vremeni.mjs держит правило дальше: любая
печать времени обязана НАЗВАТЬ пояс. Он отличает время от денег - тот же
вызов toLocaleString печатает и суммы, им пояс не нужен. Приёмка вырезанием
в обе стороны: до правки сторож нашёл ровно 20 мест, после - ноль при 303
проверенных файлах; на пустой папке честно говорит "датчик сломан", а не "чисто".
Своих проверок у сторожа десять, написаны до него.

Прогон интерфейса: 232 файла проверок, 1743 зелёных, 0 падений.
Проверка типов и разметка: 7 замечаний, все были ДО правки и не в моих файлах
(проверено прятанием своих правок и повторным прогоном).

Заодно выметен мусор в корне: файлы с именами 1', 140, 2771, 3042, 3404,
out.txt и app/toArray-скобки - обрывки чужих незакавыченных перенаправлений.

Заодно убрана мёртвая функция dirLabel в экране автоподбора: её никто не звал,
она была там до этой правки, и линтер из-за неё не пропускал весь файл.
Решение владельца. Прогон экрана: 15 файлов проверок, 84 зелёных.
2026-08-02 11:09:51 +03:00

294 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',
timeZone: 'Europe/Moscow',
});
}
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-strong">{{ 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>