Files
portal/app/resources/js/views/admin/AdminSystemView.vue
T

229 lines
7.9 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/**
* Админка SaaS → Система.
*
* Глобальные настройки SaaS-уровня (system_settings по schema v8.7 §10):
* лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.
*
* MVP — display + read-only edit-режим. Backend `/api/admin/system-settings`
* + edit-flow подключаются отдельным коммитом.
*/
import { ADMIN_SYSTEM_SETTINGS } from '../../composables/mockAdmin';
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. Инициируется mock-данными (fallback если backend недоступен),
* на mount — replace через `adminApi.listSystemSettings()`.
*
* Type-narrowing: AdminSystemSetting (mock) vs ApiSystemSetting различаются
* только origin (mock vs БД), shape совместим — оба `{key, value, type, ...}`.
*/
const settingsState = reactive<AdminSystemSetting[]>([...ADMIN_SYSTEM_SETTINGS]);
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 оставляем mock (не очищаем UI). Показываем error-banner.
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить настройки с сервера. Показаны mock-данные.');
} 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;
}
}
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError });
</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-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"
: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>