<!-- Форма правила маршрутизации: общая для создания (RulesView) и
правки (RuleDetailView). Родитель монтирует её в модали, а по кнопке
сохранения вызывает collect(): он соберёт JSON для POST/PATCH или
заполнит formError и вернёт null. -->
<script setup>
import { computed, ref } from "vue";
import {
GnButton,
GnChip,
GnIconButton,
GnInput,
GnSelect,
GnStatusCard,
GnSwitch,
GnTable,
GnTextarea,
} from "gnexus-ui-kit/vue";
import { parseList } from "../ui.js";
import { t } from "../i18n/index.js";
const props = defineProps({
// Источники и цели для селектов (из админ-списков)
sources: { type: Array, default: () => [] },
targets: { type: Array, default: () => [] },
// Существующее правило — режим редактирования, поля предзаполнятся
initial: { type: Object, default: null },
});
// --- состояние формы ---
const form = ref(props.initial ? fromRule(props.initial) : emptyForm());
function emptyForm() {
return {
name: "",
weight: 0,
throttle_seconds: "",
source: "",
subjects: "",
actions: "",
priority: "",
payload: "",
template: "",
actionRows: [{ channel: "internal_log", target_id: "", template: "" }],
};
}
// правило из API -> форма (для правки)
function fromRule(rule) {
const cond = rule.conditions || {};
return {
name: rule.name || "",
weight: rule.weight ?? 0,
throttle_seconds: rule.throttle_seconds ?? "",
source: cond.source || "",
subjects: (cond.subjects || []).join(", "),
actions: (cond.actions || []).join(", "),
priority: cond.priority_min || "",
payload: cond.payload ? JSON.stringify(cond.payload, null, 2) : "",
template: rule.template || "",
actionRows: (rule.actions || []).map((a) => ({
channel: a.channel,
target_id: a.target_id ?? "",
template: a.template || "",
})),
};
}
const PRIORITIES = computed(() => [
{ value: "", label: t("ruleForm.priorityAny") },
{ value: "low", label: "low" },
{ value: "normal", label: "normal" },
{ value: "high", label: "high" },
{ value: "critical", label: "critical" },
]);
const CHANNELS = ["telegram", "email", "s2s", "internal_log", "user"].map((c) => ({ value: c, label: c }));
const WEIGHT_TIP = computed(() => t("ruleForm.weightTip"));
const THROTTLE_TIP = computed(() => t("ruleForm.throttleTip"));
const formError = ref("");
const sourceOptions = computed(() => [{ value: "", label: t("ruleForm.anySource") }]);
function addActionRow() {
form.value.actionRows.push({ channel: "internal_log", target_id: "", template: "" });
}
function removeActionRow(i) {
form.value.actionRows.splice(i, 1);
}
/** Собрать JSON для POST/PATCH; при ошибке заполнить formError и вернуть null. */
function collect() {
formError.value = "";
if (!form.value.name.trim()) {
formError.value = t("ruleForm.errName");
return null;
}
let payload_condition = null;
try {
payload_condition = form.value.payload.trim() ? JSON.parse(form.value.payload) : null;
} catch {
formError.value = t("ruleForm.errPayloadJson");
return null;
}
const actions = form.value.actionRows
.filter((a) => a.channel)
.map((a) => ({
channel: a.channel,
...(a.target_id ? { target_id: Number(a.target_id) } : {}),
...(a.template ? { template: a.template } : {}),
}));
if (!actions.length) {
formError.value = t("ruleForm.errNoActions");
return null;
}
const conditions = {
...(form.value.source ? { source: form.value.source } : {}),
...(parseList(form.value.subjects).length ? { subjects: parseList(form.value.subjects) } : {}),
...(parseList(form.value.actions).length ? { actions: parseList(form.value.actions) } : {}),
...(form.value.priority ? { priority_min: form.value.priority } : {}),
...(payload_condition ? { payload: payload_condition } : {}),
};
return {
name: form.value.name.trim(),
weight: Number(form.value.weight) || 0,
...(form.value.throttle_seconds ? { throttle_seconds: Number(form.value.throttle_seconds) } : {}),
conditions,
...(form.value.template ? { template: form.value.template } : {}),
actions,
};
}
defineExpose({ collect, formError });
</script>
<template>
<div class="form">
<GnInput v-model="form.name" :label="t('ruleForm.nameLabel')" icon="ph-funnel" :help="t('ruleForm.nameHelp')" />
<div class="row">
<GnInput v-model="form.weight" :label="t('ruleForm.weightLabel')" icon="ph-sort-descending" :help="WEIGHT_TIP" />
<GnInput v-model="form.throttle_seconds" :label="t('ruleForm.throttleLabel')" icon="ph-timer" :help="THROTTLE_TIP" />
</div>
<div class="conditions-title">{{ t("ruleForm.conditionsTitle") }}</div>
<p class="section-note">
{{ t("ruleForm.conditionsNote") }}
</p>
<div class="row">
<GnSelect
v-model="form.source"
:label="t('ruleForm.sourceLabel')"
:options="sourceOptions.concat(props.sources.map((s) => ({ value: s.name, label: s.name })))"
/>
<GnSelect v-model="form.priority" label="Priority ≥" :options="PRIORITIES" />
</div>
<GnInput v-model="form.subjects" label="Subjects" icon="ph-shapes" :help="t('ruleForm.subjectsHelp')" />
<GnInput v-model="form.actions" label="Actions" icon="ph-lightning" :help="t('ruleForm.actionsHelp')" />
<GnTextarea
v-model="form.payload"
:label="t('ruleForm.payloadLabel')"
:help="t('ruleForm.payloadHelp')"
/>
<div class="conditions-title">{{ t("ruleForm.actionsTitle") }}</div>
<p class="section-note">
{{ t("ruleForm.actionsNote") }}
</p>
<div class="row">
<GnInput v-model="form.template" :label="t('ruleForm.tplLabel')" icon="ph-file-text" :help="t('ruleForm.tplHelp')" />
</div>
<div v-for="(a, i) in form.actionRows" :key="i" class="action-row">
<GnSelect v-model="a.channel" :label="t('ruleForm.channelLabel')" :options="CHANNELS" />
<GnSelect
v-model="a.target_id"
:label="t('ruleForm.targetLabel')"
:options="[
{ value: '', label: t('ruleForm.noTarget') },
...props.targets.filter((t) => t.channel === a.channel).map((t) => ({ value: t.id, label: t.name })),
]"
/>
<GnIconButton icon="ph-x" :label="t('ruleForm.removeAction')" @click="removeActionRow(i)" />
</div>
<GnButton variant="secondary" size="sm" icon="ph-plus" @click="addActionRow">{{ t("ruleForm.addAction") }}</GnButton>
<GnStatusCard
v-if="formError"
variant="error"
icon="ph-warning-circle"
:title="t('ruleForm.errTitle')"
:text="formError"
/>
</div>
</template>
<style scoped>
.form {
display: flex;
flex-direction: column;
gap: var(--gn-space-4);
min-width: min(640px, 80vw);
}
.row {
display: flex;
gap: var(--gn-space-4);
}
.row > * {
flex: 1;
}
.conditions-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8em;
}
.section-note {
margin: calc(-1 * var(--gn-space-2)) 0 0;
font-size: 0.82em;
opacity: 0.75;
}
.action-row {
display: flex;
gap: var(--gn-space-3);
align-items: flex-end;
}
.action-row > :first-child {
min-width: 140px;
}
.action-row > :nth-child(2) {
flex: 1;
}
</style>