253 lines
9.4 KiB
Vue
253 lines
9.4 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
/**
|
||
|
|
* Диалог редактирования system_setting (двухстадийное подтверждение + audit-log).
|
||
|
|
*
|
||
|
|
* Step 1 «edit»: новое значение + reason ≥30 chars + кнопка «Далее».
|
||
|
|
* Step 2 «confirm»: показ before/after + ещё раз подтверждение.
|
||
|
|
*
|
||
|
|
* На submit → PUT /api/admin/system-settings/{key} (audit-log пишется
|
||
|
|
* автоматически на backend). На success — emit('updated') чтобы parent
|
||
|
|
* перезагрузил список.
|
||
|
|
*/
|
||
|
|
import * as adminApi from '../../api/admin';
|
||
|
|
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
|
||
|
|
import { computed, ref, watch } from 'vue';
|
||
|
|
|
||
|
|
const props = defineProps<{
|
||
|
|
modelValue: boolean;
|
||
|
|
setting: adminApi.SystemSetting | null;
|
||
|
|
requestedBy: number;
|
||
|
|
}>();
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
(e: 'update:modelValue', value: boolean): void;
|
||
|
|
(e: 'updated', payload: adminApi.UpdateSystemSettingResponse): void;
|
||
|
|
}>();
|
||
|
|
|
||
|
|
type Step = 'edit' | 'confirm' | 'done';
|
||
|
|
|
||
|
|
const step = ref<Step>('edit');
|
||
|
|
const newValue = ref('');
|
||
|
|
const reason = ref('');
|
||
|
|
const valueError = ref<string | null>(null);
|
||
|
|
const reasonError = ref<string | null>(null);
|
||
|
|
const errorMessage = ref<string | null>(null);
|
||
|
|
const busy = ref(false);
|
||
|
|
|
||
|
|
const dialogOpen = computed({
|
||
|
|
get: () => props.modelValue,
|
||
|
|
set: (v) => emit('update:modelValue', v),
|
||
|
|
});
|
||
|
|
|
||
|
|
const reasonValid = computed(() => reason.value.trim().length >= 30);
|
||
|
|
const reasonRemaining = computed(() => Math.max(0, 30 - reason.value.trim().length));
|
||
|
|
|
||
|
|
function reset() {
|
||
|
|
step.value = 'edit';
|
||
|
|
newValue.value = props.setting?.value ?? '';
|
||
|
|
reason.value = '';
|
||
|
|
valueError.value = null;
|
||
|
|
reasonError.value = null;
|
||
|
|
errorMessage.value = null;
|
||
|
|
busy.value = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => props.modelValue,
|
||
|
|
(open) => {
|
||
|
|
if (open) reset();
|
||
|
|
},
|
||
|
|
{ immediate: true },
|
||
|
|
);
|
||
|
|
|
||
|
|
function goConfirm() {
|
||
|
|
valueError.value = null;
|
||
|
|
reasonError.value = null;
|
||
|
|
if (props.setting && newValue.value === props.setting.value) {
|
||
|
|
valueError.value = 'Новое значение совпадает со старым.';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!reasonValid.value) {
|
||
|
|
reasonError.value = 'Минимум 30 символов.';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
step.value = 'confirm';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submit() {
|
||
|
|
if (!props.setting) return;
|
||
|
|
busy.value = true;
|
||
|
|
errorMessage.value = null;
|
||
|
|
try {
|
||
|
|
const r = await adminApi.updateSystemSetting(props.setting.key, {
|
||
|
|
value: newValue.value,
|
||
|
|
reason: reason.value.trim(),
|
||
|
|
admin_user_id: props.requestedBy,
|
||
|
|
});
|
||
|
|
emit('updated', r);
|
||
|
|
step.value = 'done';
|
||
|
|
} catch (err) {
|
||
|
|
const validation = extractValidationErrors(err);
|
||
|
|
if (validation?.value?.[0]) {
|
||
|
|
valueError.value = validation.value[0];
|
||
|
|
step.value = 'edit';
|
||
|
|
} else if (validation?.reason?.[0]) {
|
||
|
|
reasonError.value = validation.reason[0];
|
||
|
|
step.value = 'edit';
|
||
|
|
} else {
|
||
|
|
errorMessage.value = extractErrorMessage(err, 'Не удалось обновить настройку.');
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
busy.value = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function close() {
|
||
|
|
dialogOpen.value = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
defineExpose({ step, newValue, reason, valueError, reasonError });
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<v-dialog v-model="dialogOpen" :max-width="540" persistent data-testid="system-setting-edit-dialog">
|
||
|
|
<v-card v-if="setting">
|
||
|
|
<v-card-title class="d-flex align-center">
|
||
|
|
<v-icon class="me-2" color="warning">mdi-cog-outline</v-icon>
|
||
|
|
Изменить настройку
|
||
|
|
</v-card-title>
|
||
|
|
<v-card-subtitle class="pb-2">
|
||
|
|
<span class="font-mono">{{ setting.key }}</span>
|
||
|
|
<v-chip class="ml-2" size="x-small" variant="tonal">{{ setting.type }}</v-chip>
|
||
|
|
</v-card-subtitle>
|
||
|
|
|
||
|
|
<v-card-text>
|
||
|
|
<v-alert
|
||
|
|
v-if="errorMessage"
|
||
|
|
type="error"
|
||
|
|
variant="tonal"
|
||
|
|
density="compact"
|
||
|
|
class="mb-3"
|
||
|
|
data-testid="error-alert"
|
||
|
|
>
|
||
|
|
{{ errorMessage }}
|
||
|
|
</v-alert>
|
||
|
|
|
||
|
|
<!-- STEP 1: edit -->
|
||
|
|
<template v-if="step === 'edit'">
|
||
|
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||
|
|
Изменение настройки будет записано в <code>saas_admin_audit_log</code> и попадёт в журнал
|
||
|
|
SaaS-админов. Действия неотменяемы — только новое изменение.
|
||
|
|
</v-alert>
|
||
|
|
<p class="text-body-2 text-medium-emphasis mb-3">{{ setting.description }}</p>
|
||
|
|
<div class="text-caption text-medium-emphasis mb-1">Текущее значение:</div>
|
||
|
|
<div class="value-block font-mono mb-3">{{ setting.value }}</div>
|
||
|
|
<v-text-field
|
||
|
|
v-model="newValue"
|
||
|
|
label="Новое значение"
|
||
|
|
variant="outlined"
|
||
|
|
density="comfortable"
|
||
|
|
:error-messages="valueError ? [valueError] : []"
|
||
|
|
data-testid="value-input"
|
||
|
|
/>
|
||
|
|
<v-textarea
|
||
|
|
v-model="reason"
|
||
|
|
label="Основание (≥30 символов)"
|
||
|
|
variant="outlined"
|
||
|
|
rows="3"
|
||
|
|
:counter="200"
|
||
|
|
:error-messages="reasonError ? [reasonError] : []"
|
||
|
|
:hint="reasonValid ? 'Готово' : `Ещё ${reasonRemaining} символов`"
|
||
|
|
persistent-hint
|
||
|
|
placeholder="Решение CTO от 09.05.2026: ослабляем лимит для UX тестирования."
|
||
|
|
data-testid="reason-input"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<!-- STEP 2: confirm -->
|
||
|
|
<template v-else-if="step === 'confirm'">
|
||
|
|
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
|
||
|
|
Подтвердите изменение. После «Применить» backend обновит настройку и запишет в audit-log.
|
||
|
|
</v-alert>
|
||
|
|
<div class="diff-block">
|
||
|
|
<div class="diff-row">
|
||
|
|
<div class="diff-label text-caption text-medium-emphasis">Было:</div>
|
||
|
|
<div class="value-block font-mono diff-before">{{ setting.value }}</div>
|
||
|
|
</div>
|
||
|
|
<div class="diff-row">
|
||
|
|
<div class="diff-label text-caption text-medium-emphasis">Станет:</div>
|
||
|
|
<div class="value-block font-mono diff-after">{{ newValue }}</div>
|
||
|
|
</div>
|
||
|
|
<div class="diff-row">
|
||
|
|
<div class="diff-label text-caption text-medium-emphasis">Основание:</div>
|
||
|
|
<div class="reason-block">{{ reason.trim() }}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<!-- STEP 3: done -->
|
||
|
|
<template v-else-if="step === 'done'">
|
||
|
|
<v-alert type="success" variant="tonal" density="compact">
|
||
|
|
Настройка обновлена и записана в audit-log.
|
||
|
|
</v-alert>
|
||
|
|
</template>
|
||
|
|
</v-card-text>
|
||
|
|
|
||
|
|
<v-card-actions>
|
||
|
|
<v-spacer />
|
||
|
|
<template v-if="step === 'edit'">
|
||
|
|
<v-btn variant="text" :disabled="busy" data-testid="cancel-btn" @click="close">Отмена</v-btn>
|
||
|
|
<v-btn color="primary" data-testid="next-btn" @click="goConfirm">Далее</v-btn>
|
||
|
|
</template>
|
||
|
|
<template v-else-if="step === 'confirm'">
|
||
|
|
<v-btn variant="text" :disabled="busy" data-testid="back-btn" @click="step = 'edit'">Назад</v-btn>
|
||
|
|
<v-btn color="primary" :loading="busy" data-testid="submit-btn" @click="submit"> Применить </v-btn>
|
||
|
|
</template>
|
||
|
|
<template v-else>
|
||
|
|
<v-btn color="primary" data-testid="close-btn" @click="close">Закрыть</v-btn>
|
||
|
|
</template>
|
||
|
|
</v-card-actions>
|
||
|
|
</v-card>
|
||
|
|
</v-dialog>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.font-mono {
|
||
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
|
|
font-feature-settings: 'tnum';
|
||
|
|
}
|
||
|
|
.value-block {
|
||
|
|
background: #f6f3ec;
|
||
|
|
padding: 6px 10px;
|
||
|
|
border-radius: 4px;
|
||
|
|
font-size: 13px;
|
||
|
|
word-break: break-all;
|
||
|
|
}
|
||
|
|
.diff-block {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
.diff-row {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
.diff-before {
|
||
|
|
background: #fde8e8;
|
||
|
|
color: #842029;
|
||
|
|
text-decoration: line-through;
|
||
|
|
}
|
||
|
|
.diff-after {
|
||
|
|
background: #d8f1e6;
|
||
|
|
color: #0a4029;
|
||
|
|
}
|
||
|
|
.reason-block {
|
||
|
|
background: #fff8e6;
|
||
|
|
padding: 8px 12px;
|
||
|
|
border-radius: 4px;
|
||
|
|
font-size: 13px;
|
||
|
|
border-left: 3px solid #b88a00;
|
||
|
|
}
|
||
|
|
</style>
|