Newer
Older
gn-synapse / frontend / src / views / TargetsView.vue
<script setup>
// Список целей каналов. Config (endpoint, chat_id, ...) — на странице цели,
// там он читаем и подсвечен; секреты только в .env.
import { onMounted, ref } from "vue";
import {
  GnButton,
  GnConfirmDialog,
  GnInput,
  GnLoader,
  GnModal,
  GnPageHeader,
  GnSelect,
  GnStatusCard,
  GnTable,
  GnTextarea,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { api } from "../api.js";
import { badgeChannel, fmtTime, th } from "../ui.js";
import { t } from "../i18n/index.js";

const toast = useToast();
const rows = ref([]);
const loading = ref(true);
const error = ref("");

const CHANNELS = ["telegram", "email", "s2s", "internal_log"];

async function load() {
  loading.value = true;
  error.value = "";
  try {
    rows.value = await api.listTargets();
  } catch (ex) {
    error.value = ex.message;
  } finally {
    loading.value = false;
  }
}

const showCreate = ref(false);
const form = ref({ channel: "telegram", name: "", description: "", config_text: "{}" });
const busy = ref(false);
const formError = ref("");

async function createTarget() {
  formError.value = "";
  let config = {};
  try {
    config = JSON.parse(form.value.config_text || "{}");
  } catch {
    formError.value = t("targets.invalidConfigJson");
    return;
  }
  busy.value = true;
  try {
    await api.createTarget({
      channel: form.value.channel,
      name: form.value.name.trim(),
      description: form.value.description,
      config,
    });
    showCreate.value = false;
    form.value = { channel: "telegram", name: "", description: "", config_text: "{}" };
    toast.success(t("targets.toastCreated"));
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    busy.value = false;
  }
}

const confirmDelete = ref(null);
async function doDelete() {
  try {
    await api.deleteTarget(confirmDelete.value.id);
    toast.success(t("targets.toastDeleted"));
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    confirmDelete.value = null;
  }
}

onMounted(load);
</script>

<template>
  <main class="container page">
    <GnPageHeader
      kicker="Synapse"
      :title="t('targets.title')"
      :subtitle="t('targets.subtitle')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="showCreate = true">{{ t("targets.createButton") }}</GnButton>
      </template>
    </GnPageHeader>

    <GnLoader v-if="loading" circle :label="t('targets.loading')" />
    <GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
    <template v-else>
      <GnTable
        :rows="rows"
        :empty-text="t('targets.empty')"
        :columns="[
          { key: 'channel', label: t('targets.colChannel') },
          { key: 'name', label: t('targets.colName') },
          { key: 'created_at', label: t('targets.colCreatedAt') },
          { key: 'actions', label: '' },
        ]"
      >
        <template #cell-channel="{ value }">
          <GnBadge :variant="badgeChannel(value)" outline>{{ value }}</GnBadge>
        </template>
        <template #cell-name="{ row }">
          <RouterLink :to="`/targets/${row.id}`" class="row-link">{{ row.name }}</RouterLink>
        </template>
        <template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
        <template #cell-actions="{ row }">
          <GnIconButton icon="ph-trash" size="sm" :label="t('targets.rowDelete')" @click="confirmDelete = row" />
        </template>
      </GnTable>
    </template>

    <!-- Создание цели -->
    <GnModal v-model:open="showCreate" :title="t('targets.modalCreate')">
      <div class="form">
        <GnSelect v-model="form.channel" :label="t('targets.formChannel')" :options="CHANNELS.map((c) => ({ value: c, label: c }))" />
        <GnInput v-model="form.name" :label="t('targets.formName')" icon="ph-tag" :help="t('targets.nameHelp')" />
        <GnTextarea v-model="form.description" :label="t('targets.formDescription')" :help="t('targets.descriptionHelp')" />
        <GnInput
          v-model="form.config_text"
          :label="t('targets.formConfigLabel')"
          icon="ph-code"
          state="error"
          :help="formError || t('targets.configHelp')"
        />
      </div>
      <template #actions>
        <GnButton variant="secondary" @click="showCreate = false">{{ t("targets.cancel") }}</GnButton>
        <GnButton variant="primary" :loading="busy" :disabled="!form.name.trim()" @click="createTarget">
          {{ t("targets.create") }}
        </GnButton>
      </template>
    </GnModal>

    <!-- Удаление цели -->
    <GnConfirmDialog
      :open="confirmDelete !== null" @update:open="(v) => (v ? null : (confirmDelete = null))"
      :key="confirmDelete?.id"
      :title="t('targets.confirmTitle')"
      :message="confirmDelete ? t('targets.confirmMessage', { name: confirmDelete.name, channel: confirmDelete.channel }) : ''"
      confirm-variant="danger"
      :confirm-text="t('targets.confirmDelete')"
      :cancel-text="t('targets.cancel')"
      @confirm="doDelete"
    />
  </main>
</template>

<style scoped>
.page {
  max-width: 1440px;
  margin-inline: auto;
  padding-block: var(--gn-space-6);
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
}
.row-link {
  color: inherit;
  text-decoration: none;
}
.row-link:hover {
  text-decoration: underline;
}
.form {
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
}
</style>