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

225 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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;
}
}
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"
: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>