225 lines
7.6 KiB
Vue
225 lines
7.6 KiB
Vue
<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>
|