<!-- Карточка источника: описание, ключи (выпуск/отзыв, токен показывается один
раз — в БД только sha256), зарегистрированные типы событий источника. -->
<script setup>
import { computed, onMounted, ref } from "vue";
import {
GnAlert,
GnBadge,
GnButton,
GnConfirmDialog,
GnCopyButton,
GnDropdown,
GnIconButton,
GnLoader,
GnModal,
GnPageHeader,
GnStatusCard,
GnTable,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../api.js";
import { t } from "../i18n/index.js";
import { fmtTime, th } from "../ui.js";
const toast = useToast();
const route = useRoute();
const router = useRouter();
const sourceId = Number(route.params.id);
const source = ref(null);
const keys = ref([]);
const types = ref([]);
const loading = ref(true);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
const [src, allTypes] = await Promise.all([api.listSources(), api.listTypes()]);
source.value = src.find((s) => s.id === sourceId) || null;
if (!source.value) {
error.value = t("sourceDetail.notFound");
return;
}
types.value = allTypes.filter((t) => t.source_id === sourceId || t.source_name === source.value.name);
keys.value = await api.listKeys(sourceId);
} catch (ex) {
error.value = ex.message;
} finally {
loading.value = false;
}
}
async function createKey() {
try {
const created = await api.createKey(sourceId, { name: source.value.name });
tokenModal.value = created;
keys.value = await api.listKeys(sourceId);
} catch (ex) {
toast.error(ex.message);
}
}
// Токен показывается один раз
const tokenModal = ref(null);
async function revokeKey(key) {
try {
await api.revokeKey(sourceId, key.id);
keys.value = await api.listKeys(sourceId);
toast.success(t("sourceDetail.toastKeyRevoked"));
} catch (ex) {
toast.error(ex.message);
}
}
const confirmDelete = ref(false);
async function doDelete() {
try {
await api.deleteSource(sourceId);
toast.success(t("sourceDetail.toastDeleted"));
router.push("/sources");
} catch (ex) {
// 409: у источника есть события — удалить нельзя
toast.error(ex.message);
confirmDelete.value = false;
}
}
const createdAt = computed(() => fmtTime(source.value?.created_at));
onMounted(load);
</script>
<template>
<main class="container page">
<RouterLink to="/sources" class="back-link"><i class="ph ph-arrow-left"></i> {{ t("sourceDetail.backToList") }}</RouterLink>
<GnPageHeader :kicker="t('sourceDetail.kicker')" :title="source?.name || '…'" :subtitle="source?.label || ''">
<template #actions>
<GnDropdown
:label="t('sourceDetail.actions')"
:items="[
{ label: t('sourceDetail.issueKey'), icon: 'ph-key', onSelect: createKey },
{ label: t('sourceDetail.deleteSource'), icon: 'ph-trash', danger: true, onSelect: () => (confirmDelete = true) },
]"
/>
</template>
</GnPageHeader>
<GnLoader v-if="loading" circle :label="t('sourceDetail.loading')" />
<GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
<template v-else>
<!-- Паспорт источника -->
<section class="panel">
<h3 class="panel-title">{{ t("sourceDetail.aboutTitle") }}</h3>
<dl class="meta">
<div class="meta-row">
<dt>{{ t("sourceDetail.dtSlug") }}</dt>
<dd><code>{{ source.name }}</code></dd>
</div>
<div class="meta-row">
<dt>{{ t("sourceDetail.dtName") }}</dt>
<dd>{{ source.label || "—" }}</dd>
</div>
<div class="meta-row">
<dt>{{ t("sourceDetail.dtDescription") }}</dt>
<dd>{{ source.description || "—" }}</dd>
</div>
<div class="meta-row">
<dt>{{ t("sourceDetail.dtCreated") }}</dt>
<dd>{{ createdAt }}</dd>
</div>
</dl>
</section>
<!-- API-ключи -->
<section class="panel">
<div class="panel-head">
<h3 class="panel-title">{{ t("sourceDetail.keysTitle") }}</h3>
<GnButton variant="primary" size="sm" icon="ph-plus" @click="createKey">{{ t("sourceDetail.issueKey") }}</GnButton>
</div>
<p class="panel-note">{{ t("sourceDetail.tokenLabel") }} <code>syn_*</code>{{ t("sourceDetail.tokenNote") }}</p>
<GnTable
:rows="keys"
:empty-text="t('sourceDetail.keysEmpty')"
:columns="[
{ key: 'name', label: t('sourceDetail.colKey') },
{ key: 'token_hint', label: t('sourceDetail.colTail') },
{ key: 'created_at', label: th(t('sourceDetail.dtCreated'), t('sourceDetail.colCreatedHelp')) },
{ key: 'last_used_at', label: th(t('sourceDetail.colUsed'), t('sourceDetail.colUsedHelp')) },
{ key: 'revoked_at', label: t('sourceDetail.colStatus') },
{ key: 'actions', label: '' },
]"
>
<template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
<template #cell-last_used_at="{ value }">{{ fmtTime(value) }}</template>
<template #cell-revoked_at="{ value }">
<GnBadge :variant="value ? 'danger' : 'success'" outline>
{{ value ? t("sourceDetail.keyRevoked") : t("sourceDetail.keyActive") }}
</GnBadge>
</template>
<template #cell-actions="{ row }">
<GnIconButton
v-if="!row.revoked_at"
icon="ph-x-circle"
size="sm"
:label="t('sourceDetail.revoke')"
@click="revokeKey(row)"
/>
</template>
</GnTable>
</section>
<!-- Типы событий -->
<section class="panel">
<div class="panel-head">
<h3 class="panel-title">{{ t("sourceDetail.typesTitle") }}</h3>
<GnButton variant="secondary" size="sm" icon="ph-shapes" @click="router.push('/types')">
{{ t("sourceDetail.allTypes") }}
</GnButton>
</div>
<GnTable
:rows="types"
:empty-text="t('sourceDetail.typesEmpty')"
:columns="[
{ key: 'subject', label: th(t('sourceDetail.colSubject'), t('sourceDetail.colSubjectHelp')) },
{ key: 'action', label: th(t('sourceDetail.colAction'), t('sourceDetail.colActionHelp')) },
{ key: 'created_at', label: th(t('sourceDetail.dtCreated'), t('sourceDetail.colTypeCreatedHelp')) },
]"
>
<template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
</GnTable>
</section>
</template>
<!-- Токен показывается один раз -->
<GnModal
v-if="tokenModal"
:open="tokenModal !== null" @update:open="(v) => (v ? null : (tokenModal = null))"
:title="t('sourceDetail.tokenModalTitle')"
:close-on-backdrop="false"
>
<GnAlert variant="warning">
<i class="ph ph-warning"></i>
{{ t("sourceDetail.tokenModalText") }}
</GnAlert>
<p class="token-wrap"><code>{{ tokenModal.token }}</code></p>
<template #actions>
<GnCopyButton class="token-copy" :text="tokenModal.token" :label="t('sourceDetail.copy')" icon="ph-copy" />
<GnButton variant="primary" @click="tokenModal = null">{{ t("sourceDetail.done") }}</GnButton>
</template>
</GnModal>
<!-- Удаление источника -->
<GnConfirmDialog
v-model:open="confirmDelete"
:title="t('sourceDetail.confirmTitle')"
:message="t('sourceDetail.confirmMessage', { name: source?.name })"
confirm-variant="danger"
:confirm-text="t('sourceDetail.delete')"
:cancel-text="t('sourceDetail.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);
}
.back-link {
width: max-content;
display: inline-flex;
align-items: center;
gap: var(--gn-space-1);
color: inherit;
text-decoration: none;
font-size: 0.85em;
opacity: 0.75;
}
.back-link:hover {
opacity: 1;
}
.panel {
background: var(--gn-surface-panel);
border: 1px solid var(--gn-border-color-muted);
border-radius: 6px;
padding: var(--gn-space-4) var(--gn-space-5);
display: flex;
flex-direction: column;
gap: var(--gn-space-3);
}
.panel-title {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8em;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gn-space-3);
}
.panel-note {
margin: 0;
font-size: 0.82em;
opacity: 0.75;
}
.meta {
margin: 0;
display: grid;
gap: var(--gn-space-2);
}
.meta-row {
display: grid;
grid-template-columns: minmax(120px, 160px) 1fr;
gap: var(--gn-space-3);
}
.meta-row dt {
color: var(--gn-color-text-medium);
font-size: 0.82em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meta-row dd {
margin: 0;
}
.token-wrap {
word-break: break-all;
background: var(--gn-surface-panel-muted);
padding: var(--gn-space-3);
border-radius: 6px;
overflow-wrap: anywhere;
}
.token-copy {
margin-right: auto;
}
</style>