Files
portal/app/resources/js/views/admin/AdminSystemView.vue
T
Дмитрий e39a42cfdf fix(a11y): admin search inputs — add label prop for accessible name (Pattern H)
A11y rescan Pattern H — Vuetify <v-text-field> без `label` prop рендерит
empty `<label id="input-v-NN-label">` (referenced via aria-labelledby).
Pa11y/axe видит unlabelled input на /admin/billing (search «Поиск по
названию или ИНН») и /admin/system (search «Поиск по ключу или описанию»).

Initial naive fix добавил `aria-label="..."` — но ARIA priority говорит
aria-labelledby overrides aria-label, поэтому осталось violation.

Final fix: add `label="Поиск"` prop on VTextField. Vuetify рендерит
floating label с правильным accessible text → axe-core resolves через
aria-labelledby chain successfully. Placeholder сохранён (split: «Поиск»
теперь в label, «по названию или ИНН» / «по ключу или описанию» —
placeholder).

Files:
- AdminBillingView.vue:209-217
- AdminSystemView.vue:130-138

Closes Pa11y «label» violations на 2 admin URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:48 +03:00

229 lines
7.9 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.
*
* 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>