2026-05-09 04:17:17 +03:00
|
|
|
|
<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';
|
2026-05-09 05:49:34 +03:00
|
|
|
|
import * as adminApi from '../../api/admin';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
import type { SystemSetting as ApiSystemSetting } from '../../api/admin';
|
2026-05-09 05:49:34 +03:00
|
|
|
|
import { extractErrorMessage } from '../../api/client';
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
2026-05-09 05:33:21 +03:00
|
|
|
|
import SystemSettingEditDialog from '../../components/admin/SystemSettingEditDialog.vue';
|
2026-05-09 04:17:17 +03:00
|
|
|
|
|
|
|
|
|
|
const search = ref('');
|
2026-05-09 05:49:34 +03:00
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const fetchError = ref<string | null>(null);
|
2026-05-09 04:17:17 +03:00
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Settings-state. Инициируется mock-данными (fallback если backend недоступен),
|
|
|
|
|
|
* на mount — replace через `adminApi.listSystemSettings()`.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Type-narrowing: AdminSystemSetting (mock) vs ApiSystemSetting различаются
|
|
|
|
|
|
* только origin (mock vs БД), shape совместим — оба `{key, value, type, ...}`.
|
|
|
|
|
|
*/
|
2026-05-09 05:33:21 +03:00
|
|
|
|
const settingsState = reactive<AdminSystemSetting[]>([...ADMIN_SYSTEM_SETTINGS]);
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 04:17:17 +03:00
|
|
|
|
const filteredSettings = computed(() => {
|
|
|
|
|
|
const q = search.value.trim().toLowerCase();
|
2026-05-09 05:33:21 +03:00
|
|
|
|
if (!q) return settingsState;
|
|
|
|
|
|
return settingsState.filter((s) => s.key.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
|
2026-05-09 04:17:17 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError });
|
2026-05-09 04:17:17 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-05-09 05:33:21 +03:00
|
|
|
|
<v-alert type="info" variant="tonal" class="mb-4" density="compact">
|
|
|
|
|
|
Edit-flow требует основания ≥30 символов и двухстадийного подтверждения; результат записывается в
|
|
|
|
|
|
<code>saas_admin_audit_log</code> с hash-chain (OPEN-И-15).
|
2026-05-09 04:17:17 +03:00
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-09 04:17:17 +03:00
|
|
|
|
<v-card variant="outlined" class="pa-4">
|
2026-05-09 05:49:34 +03:00
|
|
|
|
<div class="d-flex justify-space-between align-center mb-3 ga-2 flex-wrap">
|
2026-05-09 04:17:17 +03:00
|
|
|
|
<h2 class="text-h6 ma-0">system_settings</h2>
|
2026-05-09 05:49:34 +03:00
|
|
|
|
<v-spacer />
|
2026-05-09 04:17:17 +03:00
|
|
|
|
<v-text-field
|
|
|
|
|
|
v-model="search"
|
2026-05-14 10:07:48 +03:00
|
|
|
|
label="Поиск"
|
|
|
|
|
|
placeholder="по ключу или описанию"
|
2026-05-09 04:17:17 +03:00
|
|
|
|
prepend-inner-icon="mdi-magnify"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
variant="outlined"
|
|
|
|
|
|
hide-details
|
|
|
|
|
|
clearable
|
|
|
|
|
|
style="max-width: 320px"
|
|
|
|
|
|
/>
|
2026-05-09 05:49:34 +03:00
|
|
|
|
<v-btn
|
|
|
|
|
|
variant="outlined"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
prepend-icon="mdi-refresh"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
data-testid="reload-btn"
|
|
|
|
|
|
@click="loadSettings"
|
|
|
|
|
|
>
|
|
|
|
|
|
Обновить
|
|
|
|
|
|
</v-btn>
|
2026-05-09 04:17:17 +03:00
|
|
|
|
</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>
|
2026-05-09 05:33:21 +03:00
|
|
|
|
<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>
|
2026-05-09 04:17:17 +03:00
|
|
|
|
</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>
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
<SystemSettingEditDialog
|
|
|
|
|
|
v-model="editOpen"
|
|
|
|
|
|
:setting="editSetting"
|
|
|
|
|
|
:requested-by="ADMIN_USER_ID"
|
|
|
|
|
|
@updated="onSettingUpdated"
|
|
|
|
|
|
/>
|
2026-05-09 04:17:17 +03:00
|
|
|
|
</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>
|