feat(ui): update ui style

This commit is contained in:
RhenCloud 2026-08-16 21:25:57 +08:00
parent ca6dfd3f64
commit 47d7c9105b
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
35 changed files with 1577 additions and 591 deletions

View file

@ -0,0 +1,256 @@
<script setup lang="ts">
import type { DeliveryMetrics, Group, Route, SendRecord } from "~/types";
const props = defineProps<{
groups: Group[];
logs: SendRecord[];
metrics: DeliveryMetrics | null;
groupsLoading: boolean;
logsLoading: boolean;
metricsLoading: boolean;
}>();
const { t } = useI18n();
const router = useRouter();
const routesByGroup = ref<Record<string, Route[]>>({});
const routesLoading = ref(false);
const allRoutes = computed(() => {
const out: Array<Route & { group?: Group }> = [];
for (const g of props.groups) {
for (const r of routesByGroup.value[g.id] ?? []) out.push({ ...r, group: g });
}
return out;
});
async function loadRoutes() {
if (!props.groups.length) return;
routesLoading.value = true;
const entries = await Promise.all(
props.groups.map(async (g) => {
try {
const data = await apiFetch<{ routes?: Route[] }>(
`/admin/api/groups/${encodeURIComponent(g.id)}/routes`,
);
return [g.id, data.routes ?? []] as const;
} catch {
return [g.id, []] as const;
}
}),
);
routesByGroup.value = Object.fromEntries(entries);
routesLoading.value = false;
}
onMounted(loadRoutes);
watch(() => props.groups, loadRoutes);
const kpis = computed(() => {
const m = props.metrics;
const total = m?.total ?? 0;
const ok = m?.ok ?? 0;
const rate = total ? (ok / total) * 100 : 0;
return [
{ label: t("metrics.total"), value: total.toLocaleString(), tone: "accent" },
{ label: t("metrics.successRate"), value: `${rate.toFixed(1)}%`, tone: "ok" },
{ label: t("metrics.failed"), value: (m?.failed ?? 0).toLocaleString(), tone: "bad" },
{
label: t("metrics.avgDuration"),
value: `${(m?.avgDurationMs ?? 0).toFixed(0)}ms`,
tone: "muted",
},
];
});
const KPI_TEXT: Record<string, string> = {
accent: "text-accent",
ok: "text-ok",
bad: "text-bad",
muted: "text-text",
};
const EVENT_TONES: Record<string, string> = {
push: "ok",
pull_request: "accent",
issues: "warn",
workflow_run: "info",
check_suite: "info",
check_run: "info",
release: "ok",
deployment: "accent",
};
const EVENT_BADGE: Record<string, string> = {
ok: "bg-ok-dim text-ok",
accent: "bg-accent-dim text-accent",
warn: "bg-warn-dim text-warn",
info: "bg-info-dim text-info",
bad: "bg-bad-dim text-bad",
muted: "bg-surface-2 text-muted",
};
function eventBadge(ev: string): string {
return EVENT_BADGE[EVENT_TONES[ev] ?? "muted"] ?? EVENT_BADGE.muted!;
}
function fmtTime(ts: number): string {
const d = Date.now() - ts;
if (d < 60_000) return `${Math.max(1, Math.round(d / 1000))}s`;
if (d < 3_600_000) return `${Math.round(d / 60_000)}m`;
if (d < 86_400_000) return `${Math.round(d / 3_600_000)}h`;
return `${Math.round(d / 86_400_000)}d`;
}
function platformLabel(p?: string): string {
return p === "telegram" ? "TG" : p === "discord" ? "DC" : "—";
}
function routeEvents(r: Route): string[] {
return r.filters
.filter((f) => f.type === "event")
.flatMap((f) => (Array.isArray(f.match) ? f.match : [f.match]));
}
</script>
<template>
<div class="space-y-6">
<!-- KPI cards -->
<section class="grid grid-cols-2 gap-4 xl:grid-cols-4">
<div v-for="k in kpis" :key="k.label" class="rounded border border-border bg-surface p-5">
<div class="text-[11px] font-bold uppercase tracking-[1.5px] text-faint">{{ k.label }}</div>
<div
class="mt-2 text-2xl font-extrabold tracking-tight [font-variant-numeric:tabular-nums]"
:class="KPI_TEXT[k.tone] ?? KPI_TEXT.muted"
>
{{ metricsLoading ? "—" : k.value }}
</div>
</div>
</section>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
<!-- Routes overview -->
<section class="rounded border border-border bg-surface xl:col-span-2">
<div class="flex items-center justify-between border-b border-border px-5 py-4">
<h2 class="text-sm font-bold tracking-tight">{{ t("overview.routes") }}</h2>
<span class="text-xs text-faint [font-variant-numeric:tabular-nums]">{{
allRoutes.length
}}</span>
</div>
<div v-if="routesLoading" class="px-5 py-12 text-center text-faint">
{{ t("status.loading") }}
</div>
<div v-else-if="!allRoutes.length" class="px-5 py-12 text-center text-muted">
<p class="mb-3">{{ t("routes.emptyGroup") }}</p>
<button class="btn btn-accent btn-sm" @click="router.replace('/admin/groups')">
{{ t("routes.createFirst") }}
</button>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full min-w-[560px] text-left text-[13px]">
<thead>
<tr
class="border-b border-border text-[10px] font-bold uppercase tracking-[1.2px] text-faint"
>
<th class="px-5 py-2.5">Route</th>
<th class="px-3 py-2.5">Group</th>
<th class="px-3 py-2.5">Events</th>
<th class="px-3 py-2.5">Targets</th>
<th class="px-5 py-2.5 text-right">State</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in allRoutes.slice(0, 8)"
:key="r.id"
class="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-surface-2"
@click="router.replace('/admin/groups')"
>
<td class="px-5 py-3">
<div class="font-semibold text-text">{{ r.name || r.id }}</div>
<div class="font-mono text-[11px] text-faint">{{ r.id }}</div>
</td>
<td class="px-3 py-3 text-muted">{{ r.group?.name || "—" }}</td>
<td class="px-3 py-3">
<div class="flex flex-wrap gap-1">
<span
v-for="ev in routeEvents(r).slice(0, 2)"
:key="ev"
class="rounded-full bg-accent-dim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent"
>
{{ ev }}
</span>
<span v-if="routeEvents(r).length > 2" class="text-[10px] text-faint">
+{{ routeEvents(r).length - 2 }}
</span>
</div>
</td>
<td class="px-3 py-3">
<div class="flex items-center gap-1">
<span
v-for="tg in r.targets.slice(0, 3)"
:key="tg.channelId ?? tg.chatId ?? platformLabel(tg.platform)"
class="rounded border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
>
{{ platformLabel(tg.platform) }}
</span>
<span v-if="r.targets.length > 3" class="text-[10px] text-faint">
+{{ r.targets.length - 3 }}
</span>
</div>
</td>
<td class="px-5 py-3 text-right">
<span class="dot" :class="r.enabled ? 'ok' : 'bad'" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Recent send logs -->
<section class="rounded border border-border bg-surface">
<div class="flex items-center justify-between border-b border-border px-5 py-4">
<h2 class="text-sm font-bold tracking-tight">{{ t("overview.recent") }}</h2>
<button class="text-xs font-semibold text-accent" @click="router.replace('/admin/logs')">
{{ t("overview.viewAll") }}
</button>
</div>
<div v-if="logsLoading" class="px-5 py-12 text-center text-faint">
{{ t("status.loading") }}
</div>
<div v-else-if="!logs.length" class="px-5 py-12 text-center text-faint">
{{ t("metrics.empty") }}
</div>
<ul v-else class="divide-y divide-border">
<li
v-for="log in logs.slice(0, 10)"
:key="log.id ?? `${log.ts}-${log.target}`"
class="flex items-center gap-3 px-5 py-3"
>
<span class="dot" :class="log.ok ? 'ok' : 'bad'" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide"
:class="eventBadge(log.event)"
>
{{ log.event }}
</span>
<span class="truncate text-[12px] text-muted">{{ log.repo || log.target }}</span>
</div>
<div class="mt-0.5 truncate font-mono text-[11px] text-faint">{{ log.target }}</div>
</div>
<span class="text-[11px] font-medium text-faint [font-variant-numeric:tabular-nums]">
{{ fmtTime(log.ts) }}
</span>
</li>
</ul>
</section>
</div>
</div>
</template>

View file

@ -1,123 +1,181 @@
<template>
<div class="shell">
<header class="header">
<div class="brand">
<div class="flex min-h-screen bg-bg text-text">
<!-- Sidebar -->
<aside
class="sticky top-0 z-30 flex h-screen w-[240px] flex-shrink-0 flex-col border-r border-border bg-surface max-lg:hidden"
>
<div class="flex items-center gap-3 px-5 py-5">
<div class="brand-mark">WH</div>
<div>
<h1>WebHooker</h1>
<span class="tagline">{{ t("app.tagline") }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
<button
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
class="btn btn-accent"
@click="openNew"
>
{{ t("app.newRoute") }}
</button>
<button
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
class="btn btn-accent"
@click="openNewGroup"
>
{{ t("app.newGroup") }}
</button>
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">{{ t("app.signOut") }}</a>
</div>
</header>
<main class="main">
<div v-if="needLogin" class="login">
<div class="login-card">
<h2>{{ t("login.title") }}</h2>
<p v-if="forbidden">{{ t("login.forbidden") }}</p>
<p v-else>{{ t("login.prompt") }}</p>
<a class="btn btn-accent btn-lg" href="/admin/login">{{ t("login.button") }}</a>
<div class="text-sm font-extrabold tracking-tight">WebHooker</div>
<div class="text-[10px] font-semibold uppercase tracking-[2px] text-faint">
{{ t("app.tagline") }}
</div>
</div>
</div>
<template v-else>
<!-- Group detail view -->
<template v-if="selectedGroup">
<section class="toolbar">
<div class="crumbs">
<button class="btn btn-ghost btn-sm" @click="exitGroup">{{ t("group.back") }}</button>
<span class="kpi-label">{{ t("group.routesIn", { name: selectedGroup.name }) }}</span>
<span class="kpi">{{ groupRoutes.length }}</span>
</div>
<div class="status">
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
</div>
</section>
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
<section class="routes">
<RouteCard
v-for="(r, i) in groupRoutes"
:key="r.id"
:route="r"
:at-first="i === 0"
:at-last="i === groupRoutes.length - 1"
:readonly="!canEditRoutes(selectedGroup.id)"
:style="{ animationDelay: i * 45 + 'ms' }"
@toggle="onToggle"
@edit="openEdit"
@delete="onDelete"
@move="onMove"
/>
</section>
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
<p>{{ t("routes.emptyGroup") }}</p>
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
{{ t("routes.createFirst") }}
</button>
</section>
<MembersPanel
:group="selectedGroup"
:can-edit="canEditGroup(selectedGroup.id)"
:saving="savingGroup"
@save="onSaveGroupFromPanel"
<nav class="flex-1 space-y-0.5 px-3">
<button
v-for="n in nav"
:key="n.id"
class="flex w-full items-center gap-3 rounded-[8px] px-3 py-2 text-[13px] font-semibold transition-colors"
:class="
n.id === activeNav
? 'bg-accent-dim text-accent'
: 'text-muted hover:bg-surface-2 hover:text-text'
"
@click="switchView(n.id)"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="n.id === activeNav ? 'bg-accent' : 'bg-border-strong'"
/>
{{ n.label }}
</button>
</nav>
<WebhookPanel
v-if="canEditGroup(selectedGroup.id)"
:group-id="selectedGroup.id"
:can-edit="canEditGroup(selectedGroup.id)"
/>
</template>
<div class="border-t border-border px-3 py-4">
<a
class="block rounded-[8px] px-3 py-2 text-[13px] font-semibold text-muted transition-colors hover:bg-surface-2"
href="/admin/logout"
>
{{ t("app.signOut") }}
</a>
</div>
</aside>
<!-- Main column -->
<div class="flex min-w-0 flex-1 flex-col">
<header
class="sticky top-0 z-20 flex items-center justify-between gap-4 border-b border-border px-6 py-3"
style="background: var(--header-bg); backdrop-filter: blur(12px)"
>
<div>
<h1 class="text-[15px] font-extrabold tracking-tight">{{ pageTitle }}</h1>
<p v-if="needLogin" class="text-[11px] text-faint">{{ t("login.title") }}</p>
<p v-else class="text-[11px] text-faint">
{{ loadingAny ? t("status.loading") : t("status.connected") }}
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="view === 'overview'"
class="btn btn-ghost btn-sm"
@click="refreshOverview"
>
{{ t("metrics.refresh") }}
</button>
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
<button
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
class="btn btn-accent btn-sm"
@click="openNew"
>
{{ t("app.newRoute") }}
</button>
<button
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
class="btn btn-accent btn-sm"
@click="openNewGroup"
>
{{ t("app.newGroup") }}
</button>
</div>
</header>
<!-- Mobile nav -->
<nav class="flex gap-1 overflow-x-auto border-b border-border px-4 py-2 lg:hidden">
<button
v-for="n in nav"
:key="n.id"
class="whitespace-nowrap rounded-[7px] px-3 py-1.5 text-[13px] font-semibold transition-colors"
:class="n.id === activeNav ? 'bg-accent-dim text-accent' : 'text-muted'"
@click="switchView(n.id)"
>
{{ n.label }}
</button>
</nav>
<main class="mx-auto w-full max-w-[1180px] px-6 pb-24 pt-6 max-sm:px-4">
<div v-if="needLogin" class="login">
<div class="login-card">
<h2>{{ t("login.title") }}</h2>
<p v-if="forbidden">{{ t("login.forbidden") }}</p>
<p v-else>{{ t("login.prompt") }}</p>
<a class="btn btn-accent btn-lg" href="/admin/login">{{ t("login.button") }}</a>
</div>
</div>
<!-- Top-level views -->
<template v-else>
<nav class="tabs">
<button
class="tab"
:class="{ active: view === 'groups' }"
@click="switchView('groups')"
>
{{ t("tab.groups") }}
</button>
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
{{ t("tab.logs") }}
</button>
<button class="tab" :class="{ active: view === 'audit' }" @click="switchView('audit')">
{{ t("tab.audit") }}
</button>
<button
class="tab"
:class="{ active: view === 'metrics' }"
@click="switchView('metrics')"
>
{{ t("tab.metrics") }}
</button>
</nav>
<!-- Overview dashboard -->
<AdminHome
v-if="view === 'overview'"
:key="refreshKey"
:groups="groups"
:logs="logs"
:metrics="metrics"
:groups-loading="groupsLoading"
:logs-loading="logsLoading"
:metrics-loading="metricsLoading"
/>
<template v-if="view === 'groups'">
<!-- Group detail view -->
<template v-else-if="selectedGroup">
<section class="toolbar">
<div class="crumbs">
<button class="btn btn-ghost btn-sm" @click="exitGroup">
{{ t("group.back") }}
</button>
<span class="kpi-label">{{ t("group.routesIn", { name: selectedGroup.name }) }}</span>
<span class="kpi">{{ groupRoutes.length }}</span>
</div>
<div class="status">
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
</div>
</section>
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
<section class="routes">
<RouteCard
v-for="(r, i) in groupRoutes"
:key="r.id"
:route="r"
:at-first="i === 0"
:at-last="i === groupRoutes.length - 1"
:readonly="!canEditRoutes(selectedGroup.id)"
:style="{ animationDelay: i * 45 + 'ms' }"
@toggle="onToggle"
@edit="openEdit"
@delete="onDelete"
@move="onMove"
/>
</section>
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
<p>{{ t("routes.emptyGroup") }}</p>
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
{{ t("routes.createFirst") }}
</button>
</section>
<MembersPanel
:group="selectedGroup"
:can-edit="canEditGroup(selectedGroup.id)"
:saving="savingGroup"
@save="onSaveGroupFromPanel"
/>
<WebhookPanel
v-if="canEditGroup(selectedGroup.id)"
:group-id="selectedGroup.id"
:can-edit="canEditGroup(selectedGroup.id)"
/>
</template>
<!-- Top-level views -->
<template v-else-if="view === 'groups'">
<section class="toolbar">
<div>
<span class="kpi-label">{{ t("kpi.groups") }}</span>
@ -223,12 +281,16 @@
:metrics="metrics"
:loading="metricsLoading"
:error="metricsError"
@refresh="loadMetrics"
:groups="groups"
:selected-group-id="metricsFilterGroup"
@refresh="loadMetrics(metricsFilterGroup || undefined)"
@filter="loadMetrics(metricsFilterGroup || undefined)"
@update:selected-group-id="metricsFilterGroup = $event"
/>
</template>
</template>
</template>
</main>
</main>
</div>
<RouteEditor
:open="editorOpen"
@ -253,6 +315,7 @@
import type { Group, Route } from "~/types";
import { useAuditApi } from "~/composables/useAudit";
import WebhookPanel from "~/components/WebhookPanel.vue";
import AdminHome from "~/components/AdminHome.vue";
const { t, toggle } = useI18n();
const { push } = useToasts();
@ -260,13 +323,16 @@ const route = useRoute();
const router = useRouter();
/**
* The console view mirrors the URL path so tabs are deep-linkable:
* /admin (groups) · /admin/logs · /admin/audit. Unknown slugs 404.
* The console view mirrors the URL path so navigation is deep-linkable:
* /admin (overview) · /admin/groups · /admin/logs · /admin/audit · /admin/metrics.
* Unknown slugs 404.
*/
const view = computed<"groups" | "logs" | "audit" | "metrics" | null>(() => {
type View = "overview" | "groups" | "logs" | "audit" | "metrics";
const view = computed<View | null>(() => {
const seg = route.path.split("/").filter(Boolean)[1];
if (!seg) return "groups";
if (seg === "groups" || seg === "logs" || seg === "audit" || seg === "metrics") return seg;
if (!seg) return "overview";
if (seg === "overview" || seg === "groups" || seg === "logs" || seg === "audit" || seg === "metrics")
return seg;
return null;
});
@ -274,6 +340,43 @@ if (view.value === null) {
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
}
const nav = computed(() => [
{ id: "overview" as View, label: t("tab.overview"), path: "/admin" },
{ id: "groups" as View, label: t("tab.groups"), path: "/admin/groups" },
{ id: "logs" as View, label: t("tab.logs"), path: "/admin/logs" },
{ id: "audit" as View, label: t("tab.audit"), path: "/admin/audit" },
{ id: "metrics" as View, label: t("tab.metrics"), path: "/admin/metrics" },
]);
const activeNav = computed<View>(() => view.value ?? "overview");
const pageTitle = computed(() => {
if (selectedGroup.value) return t("group.routesIn", { name: selectedGroup.value.name });
switch (view.value) {
case "overview":
return t("tab.overview");
case "groups":
return t("tab.groups");
case "logs":
return t("tab.logs");
case "audit":
return t("tab.audit");
case "metrics":
return t("tab.metrics");
default:
return "";
}
});
const loadingAny = computed(
() =>
groupsLoading.value ||
logsLoading.value ||
metricsLoading.value ||
groupRoutesLoading.value ||
auditLoading.value,
);
const { logs, loading: logsLoading, error: logsError, load: loadLogs } = useSendLogs();
const {
entries: auditEntries,
@ -316,6 +419,8 @@ const savingGroup = ref(false);
const logFilterGroup = ref("");
const auditFilterGroup = ref("");
const metricsFilterGroup = ref("");
const refreshKey = ref(0);
onMounted(() => {
if (typeof window !== "undefined") {
@ -328,6 +433,7 @@ onMounted(() => {
}
loadGroups();
loadLogs(50, logFilterGroup.value || undefined);
loadMetrics();
});
// Tab switches are client-side navigations (no remount), so load the audit
@ -336,18 +442,25 @@ watch(
view,
(v) => {
if (v === "audit") loadAudit(50, auditFilterGroup.value || undefined);
if (v === "metrics") loadMetrics();
if (v === "metrics") loadMetrics(metricsFilterGroup.value || undefined);
},
{ immediate: true },
);
function switchView(next: "groups" | "logs" | "audit" | "metrics"): void {
const path = next === "groups" ? "/admin" : `/admin/${next}`;
function switchView(next: View): void {
const path = next === "overview" ? "/admin" : `/admin/${next}`;
if (route.path !== path) router.replace(path);
}
async function refreshOverview(): Promise<void> {
refreshKey.value++;
await Promise.all([loadGroups(), loadLogs(50, logFilterGroup.value || undefined), loadMetrics()]);
push(t("overview.refreshed"));
}
function enterGroup(group: Group): void {
selectedGroup.value = group;
groupRoutes.value = [];
loadGroupRoutes(group.id);
}

View file

@ -6,55 +6,64 @@
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
<div class="editor-heading">
<span class="editor-eyebrow">{{ t("groupEditor.eyebrow") }}</span>
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
</div>
<button class="icon-btn" :title="t('groupEditor.close')" @click="close"></button>
</div>
<form class="editor-body" @submit.prevent="save">
<div class="row2">
<div class="field">
<label>{{ t("groupEditor.name") }}</label>
<input
v-model="form.name"
type="text"
class="input"
:placeholder="t('groupEditor.namePlaceholder')"
required
/>
</div>
<div class="field">
<label>{{ t("groupEditor.id") }}</label>
<input
v-model="form.id"
type="text"
class="input"
:placeholder="t('groupEditor.idPlaceholder')"
required
/>
<div class="hint">
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionBasic") }}</h3>
<div class="row2">
<div class="field">
<label>{{ t("groupEditor.name") }}</label>
<input
v-model="form.name"
type="text"
class="input"
:placeholder="t('groupEditor.namePlaceholder')"
required
/>
</div>
<div class="field">
<label>{{ t("groupEditor.id") }}</label>
<input
v-model="form.id"
type="text"
class="input"
:placeholder="t('groupEditor.idPlaceholder')"
required
/>
<div class="hint">
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
</div>
</div>
</div>
</div>
<div class="row2">
<div class="field">
<label>{{ t("groupEditor.language") }}</label>
<input
v-model="form.lang"
type="text"
class="input"
:placeholder="t('groupEditor.langPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.langHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.emoji") }}
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
>
<label class="inline">
<input v-model="form.emoji" type="checkbox" />
<span>{{ t("groupEditor.emojiLabel") }}</span>
</label>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionPreferences") }}</h3>
<div class="row2">
<div class="field">
<label>{{ t("groupEditor.language") }}</label>
<input
v-model="form.lang"
type="text"
class="input"
:placeholder="t('groupEditor.langPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.langHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.emoji") }}
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
>
<label class="inline">
<input v-model="form.emoji" type="checkbox" />
<span>{{ t("groupEditor.emojiLabel") }}</span>
</label>
</div>
</div>
<div class="field">
<label
@ -91,115 +100,124 @@
</button>
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.membersNote") }}
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
>
<p class="hint">
{{ t("groupEditor.membersGoPanel") }}
</p>
</div>
<div v-if="superAdmin" class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
>
<input
v-model="form.owners"
type="text"
class="input"
:placeholder="t('groupEditor.ownersPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
</div>
<div v-else class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
>
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
</div>
<div class="field">
<label
>{{ t("groupEditor.providers") }}
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
>
<div class="flex flex-wrap gap-4">
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('github')"
@change="toggleProvider('github', $event)"
/>
<span>GitHub</span>
</label>
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('gitea')"
@change="toggleProvider('gitea', $event)"
/>
<span>Gitea</span>
</label>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionAccess") }}</h3>
<div class="field">
<label
>{{ t("groupEditor.membersNote") }}
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
>
<p class="hint">
{{ t("groupEditor.membersGoPanel") }}
</p>
</div>
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.installationId") }}
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
>
<input
v-model="form.installationId"
type="text"
class="input"
inputmode="numeric"
:placeholder="t('groupEditor.installationIdPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.logTarget") }}
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
>
<select v-model="form.logPlatform" class="select">
<option value="">{{ t("groupEditor.logDisabled") }}</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="form.logPlatform === 'discord'">
<div v-if="superAdmin" class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
>
<input
v-model="form.logChannelId"
v-model="form.owners"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.channelPlaceholder')"
class="input"
:placeholder="t('groupEditor.ownersPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
</div>
<div v-else class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
>
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionProviders") }}</h3>
<div class="field">
<label
>{{ t("groupEditor.providers") }}
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
>
<div class="flex flex-wrap gap-4">
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('github')"
@change="toggleProvider('github', $event)"
/>
<span>GitHub</span>
</label>
<label class="inline">
<input
type="checkbox"
:checked="form.providers.includes('gitea')"
@change="toggleProvider('gitea', $event)"
/>
<span>Gitea</span>
</label>
</div>
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
</div>
<div class="field">
<label
>{{ t("groupEditor.installationId") }}
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
>
<input
v-model="form.logThreadId"
v-model="form.installationId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.threadPlaceholder')"
class="input"
inputmode="numeric"
:placeholder="t('groupEditor.installationIdPlaceholder')"
/>
</template>
<template v-else-if="form.logPlatform === 'telegram'">
<input
v-model="form.logChatId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="form.logTopicId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
</div>
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionLog") }}</h3>
<div class="field">
<label
>{{ t("groupEditor.logTarget") }}
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
>
<select v-model="form.logPlatform" class="select">
<option value="">{{ t("groupEditor.logDisabled") }}</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="form.logPlatform === 'discord'">
<input
v-model="form.logChannelId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.channelPlaceholder')"
/>
<input
v-model="form.logThreadId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else-if="form.logPlatform === 'telegram'">
<input
v-model="form.logChatId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="form.logTopicId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
</div>
</section>
<div class="err">{{ formError }}</div>
</form>
<div class="editor-foot">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { DeliveryMetrics } from "~/types";
import type { DeliveryMetrics, Group, MetricsBreakdown } from "~/types";
const { t } = useI18n();
@ -7,74 +7,229 @@ defineProps<{
metrics: DeliveryMetrics | null;
loading: boolean;
error: string;
groups: Group[];
selectedGroupId: string;
}>();
const emit = defineEmits<{ refresh: [] }>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "filter"): void;
(e: "update:selectedGroupId", value: string): void;
}>();
const sortBy = ref<"total" | "failed" | "rate">("total");
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function pctOf(count: number, total: number): string {
if (total <= 0) return "0%";
return `${((count / total) * 100).toFixed(0)}%`;
}
function onGroupFilter(e: Event): void {
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
emit("filter");
}
function eventLabel(raw: string): string {
const key = `event.${raw}`;
const label = t(key);
return label === key ? raw : label;
}
const TONE: Record<string, string> = {
accent: "metric-kpi accent",
ok: "metric-kpi ok",
bad: "metric-kpi bad",
warn: "metric-kpi warn",
info: "metric-kpi info",
};
const summary = (m: DeliveryMetrics) => [
{
label: t("metrics.total"),
value: m.total.toLocaleString(),
tone: "accent",
},
{
label: t("metrics.successRate"),
value: pct(1 - m.failureRate),
tone: "ok",
},
{
label: t("metrics.failed"),
value: m.failed.toLocaleString(),
tone: "bad",
},
{
label: t("metrics.avgDuration"),
value: `${m.avgDurationMs.toFixed(0)}ms`,
tone: "warn",
},
{
label: t("metrics.retryRate"),
value: m.totalAttempts > m.total ? pct((m.totalAttempts - m.total) / m.total) : "0%",
tone: "info",
},
];
function rows(list: MetricsBreakdown[], key: (r: MetricsBreakdown) => string) {
const raw = list.map((r) => ({
key: key(r),
rawKey: key(r),
total: r.total,
ok: r.ok,
failed: r.failed,
rate: r.total > 0 ? r.failed / r.total : 0,
}));
if (sortBy.value === "total") {
raw.sort((a, b) => b.total - a.total);
} else if (sortBy.value === "failed") {
raw.sort((a, b) => b.failed - a.failed || b.total - a.total);
} else {
raw.sort((a, b) => b.rate - a.rate || b.failed - a.failed);
}
return raw;
}
function sortLabel(): string {
if (sortBy.value === "total") return t("metrics.sortTotal");
if (sortBy.value === "failed") return t("metrics.sortFailed");
return t("metrics.sortRate");
}
function cycleSort(): void {
if (sortBy.value === "total") sortBy.value = "failed";
else if (sortBy.value === "failed") sortBy.value = "rate";
else sortBy.value = "total";
}
</script>
<template>
<div class="log-toolbar">
<span class="kpi-label">{{ t("metrics.title") }}</span>
<button class="btn btn-ghost btn-sm" @click="emit('refresh')">
{{ t("metrics.refresh") }}
</button>
</div>
<div>
<div class="log-toolbar">
<span class="kpi-label">{{ t("metrics.title") }}</span>
<div class="log-filters">
<label class="filter-label">{{ t("metrics.filterGroup") }}</label>
<select
class="filter-select"
:value="selectedGroupId"
:disabled="loading"
@change="onGroupFilter"
>
<option value="">{{ t("metrics.allGroups") }}</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
{{ g.name || g.id }}
</option>
</select>
</div>
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="emit('refresh')">
{{ t("metrics.refresh") }}
</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<template v-else-if="metrics">
<div class="log-list">
<article class="log-entry">
<div class="log-meta">
<span class="kpi-label">{{ t("metrics.total") }}: {{ metrics.total }}</span>
<span class="kpi-label">{{ t("metrics.ok") }}: {{ metrics.ok }}</span>
<span class="kpi-label">{{ t("metrics.failed") }}: {{ metrics.failed }}</span>
<span class="kpi-label"
>{{ t("metrics.failureRate") }}: {{ pct(metrics.failureRate) }}</span
<p v-if="error" class="err">{{ error }}</p>
<p v-else-if="!loading && !metrics" class="empty-log">{{ t("metrics.empty") }}</p>
<template v-else-if="metrics">
<div class="metric-kpis">
<div
v-for="k in summary(metrics)"
:key="k.label"
class="metric-kpi"
:class="TONE[k.tone] ?? ''"
>
<span class="metric-kpi-value">{{ k.value }}</span>
<span class="metric-kpi-label">{{ k.label }}</span>
</div>
</div>
<article v-if="metrics.byPlatform.length" class="metric-block">
<h3 class="metric-block-title">{{ t("metrics.byPlatform") }}</h3>
<div
v-for="r in rows(metrics.byPlatform, (r) => r.platform ?? 'unknown')"
:key="r.key"
class="metric-row"
>
<span class="metric-key">{{ r.key }}</span>
<div class="metric-bar">
<span
v-if="r.ok > 0"
class="metric-fill ok"
:style="{ flex: String(r.ok) }"
:title="`${t('metrics.ok')} ${r.ok} (${pctOf(r.ok, r.total)})`"
></span>
<span
v-if="r.failed > 0"
class="metric-fill bad"
:style="{ flex: String(r.failed), minWidth: '4px' }"
:title="`${t('metrics.failed')} ${r.failed} (${pctOf(r.failed, r.total)})`"
></span>
</div>
<span class="metric-total">{{ r.total }}</span>
</div>
</article>
<article v-if="metrics.byEvent.length" class="metric-block">
<div class="metric-block-head">
<h3 class="metric-block-title">{{ t("metrics.byEvent") }}</h3>
<button class="metric-sort-toggle" @click="cycleSort">{{ sortLabel() }}</button>
</div>
<div
v-for="r in rows(metrics.byEvent, (r) => r.event ?? 'unknown')"
:key="r.key"
class="metric-row"
>
<span class="metric-key" :title="r.rawKey">{{ eventLabel(r.rawKey) }}</span>
<div class="metric-bar">
<span
v-if="r.ok > 0"
class="metric-fill ok"
:style="{ flex: String(r.ok), minWidth: r.ok > 0 ? '4px' : '0' }"
:title="`${t('metrics.ok')} ${r.ok} (${pctOf(r.ok, r.total)})`"
></span>
<span
v-if="r.failed > 0"
class="metric-fill bad"
:style="{ flex: String(r.failed), minWidth: '4px' }"
:title="`${t('metrics.failed')} ${r.failed} (${pctOf(r.failed, r.total)})`"
></span>
</div>
<span class="metric-total">{{ r.total }}</span>
</div>
</article>
<article v-if="metrics.byStatus.length" class="metric-block">
<h3 class="metric-block-title">{{ t("metrics.byStatus") }}</h3>
<div class="metric-status-row">
<span
v-for="s in metrics.byStatus"
:key="s.status"
class="metric-status"
:class="{ ok: !s.status.startsWith('5') && s.status !== '0', bad: s.status.startsWith('5') || s.status === '0' }"
>
<span class="kpi-label">
{{ t("metrics.avgDuration") }}: {{ metrics.avgDurationMs.toFixed(0) }}ms
</span>
<span class="kpi-label">
{{ t("metrics.avgAttempts") }}: {{ metrics.avgAttempts.toFixed(1) }}
{{ s.status }} · {{ s.count }}
</span>
</div>
</article>
<article v-if="metrics.byPlatform.length" class="log-entry">
<h3 class="log-head">{{ t("metrics.byPlatform") }}</h3>
<div v-for="p in metrics.byPlatform" :key="p.platform" class="log-meta">
<span class="kpi-label">{{ p.platform ?? "unknown" }}</span>
<span class="kpi-label">{{ t("metrics.ok") }}: {{ p.ok }}</span>
<span class="kpi-label">{{ t("metrics.failed") }}: {{ p.failed }}</span>
</div>
</article>
<article v-if="metrics.byEvent.length" class="log-entry">
<h3 class="log-head">{{ t("metrics.byEvent") }}</h3>
<div v-for="e in metrics.byEvent" :key="e.event" class="log-meta">
<span class="kpi-label">{{ e.event ?? "unknown" }}</span>
<span class="kpi-label">{{ t("metrics.ok") }}: {{ e.ok }}</span>
<span class="kpi-label">{{ t("metrics.failed") }}: {{ e.failed }}</span>
</div>
</article>
<article v-if="metrics.recentFailures.length" class="log-entry">
<h3 class="log-head">{{ t("metrics.recentFailures") }}</h3>
<article v-if="metrics.recentFailures.length" class="metric-block">
<h3 class="metric-block-title">{{ t("metrics.recentFailures") }}</h3>
<div
v-for="f in metrics.recentFailures"
:key="f.id ?? `${f.ts}-${f.target}`"
class="log-entry"
class="log-entry fail"
>
<span class="log-route">{{ f.event }}</span>
<span class="log-meta">{{ f.target }}</span>
<span class="log-status ok" v-if="f.errorCode">{{ f.errorCode }}</span>
<div class="log-head">
<span class="dot bad"></span>
<span class="log-route">{{ f.event }}</span>
<span v-if="f.errorCode" class="log-status bad">{{ f.errorCode }}</span>
<span class="log-time">{{ f.target }}</span>
</div>
</div>
</article>
</div>
</template>
<p v-else-if="!loading" class="empty-log">{{ t("metrics.empty") }}</p>
</template>
</template>
</div>
</template>

View file

@ -1,86 +1,96 @@
<template>
<article class="card" :class="{ disabled: !route.enabled }">
<div class="card-head">
<div class="card-title">
<article class="route-card" :class="{ disabled: !route.enabled }">
<div class="route-card-main">
<div class="route-card-header">
<label v-if="!readonly" class="switch">
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
<span class="track"></span>
</label>
<span v-if="readonly" class="dot" :class="route.enabled ? 'ok' : 'bad'"></span>
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
<span class="route-id">{{ route.id }}</span>
<div class="route-card-title">
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
<span class="route-id">{{ route.id }}</span>
</div>
<div class="route-card-badges">
<span
v-for="(tg, i) in route.targets"
:key="i"
class="route-badge"
:class="tg.platform === 'telegram' ? 'route-badge-tg' : 'route-badge-dc'"
>
<span class="route-badge-dot" :class="tg.platform === 'telegram' ? 'bg-info' : 'bg-accent'"></span>
{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}
</span>
<span v-if="route.fallback" class="route-badge route-badge-fallback">{{ t("route.fallback") }}</span>
<span v-if="route.stop" class="route-badge route-badge-stop">{{ t("route.stop") }}</span>
<span v-if="route.discordRoleIds?.length" class="route-badge route-badge-role">@roles</span>
</div>
</div>
<div class="route-card-filters">
<span
v-for="(tg, i) in route.targets"
v-for="(f, i) in route.filters"
:key="i"
class="badge"
:class="tg.platform === 'telegram' ? 'fallback' : 'lang'"
>{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}</span
class="route-chip"
:class="{ exclude: f.exclude }"
>
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
<span v-if="route.stop" class="badge stop">{{ t("route.stop") }}</span>
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
<span class="route-chip-type">{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span>
<span class="route-chip-val">{{ fmtMatch(f.match) }}</span>
</span>
<span v-if="!route.filters.length" class="route-chip route-chip-empty">
<span class="route-chip-type">{{ t("route.noFilters") }}</span>
</span>
</div>
<div class="card-actions">
<template v-if="!readonly">
<button
class="icon-btn"
:disabled="atFirst"
:title="t('route.moveUp')"
@click="$emit('move', route, -1)"
>
</button>
<button
class="icon-btn"
:disabled="atLast"
:title="t('route.moveDown')"
@click="$emit('move', route, 1)"
>
</button>
<button
class="icon-btn"
:title="t('routeEditor.editTitle')"
@click="$emit('edit', route)"
>
</button>
<button
class="icon-btn danger"
:title="t('routeEditor.close')"
@click="$emit('delete', route)"
>
</button>
</template>
<div class="route-card-targets" v-if="route.targets.length">
<div v-for="(tg, i) in route.targets" :key="i" class="route-target">
<div class="route-target-row">
<span class="route-target-label">
<template v-if="tg.platform === 'telegram'">{{ t("route.chat") }}</template>
<template v-else-if="tg.threadId">{{ t("route.thread") }}</template>
<template v-else>{{ t("route.channel") }}</template>
</span>
<code class="route-target-id">
<template v-if="tg.platform === 'telegram'">{{ tg.chatId }}</template>
<template v-else-if="tg.threadId">{{ tg.threadId }}</template>
<template v-else>{{ tg.channelId }}</template>
</code>
</div>
</div>
</div>
</div>
<div class="chips">
<span v-for="(f, i) in route.filters" :key="i" class="chip" :class="{ exclude: f.exclude }">
<span class="f-type"
>{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span
>
<span class="f-val">{{ fmtMatch(f.match) }}</span>
</span>
<span v-if="!route.filters.length" class="chip"
><span class="f-type">{{ t("route.noFilters") }}</span></span
<div v-if="!readonly" class="route-card-actions">
<button
class="route-action-btn"
:disabled="atFirst"
:title="t('route.moveUp')"
@click="$emit('move', route, -1)"
>
</div>
<div class="target">
<div v-for="(tg, i) in route.targets" :key="i" class="flex flex-wrap gap-x-[18px] gap-y-2">
<span
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
>
<span v-if="tg.platform === 'telegram' && tg.topicId"
><b>{{ t("route.topic") }}</b
><code>{{ tg.topicId }}</code></span
>
<span v-else-if="tg.platform !== 'telegram' && tg.threadId"
><b>{{ t("route.thread") }}</b
><code>{{ tg.threadId }}</code></span
>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>
</button>
<button
class="route-action-btn"
:disabled="atLast"
:title="t('route.moveDown')"
@click="$emit('move', route, 1)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<button
class="route-action-btn"
:title="t('routeEditor.editTitle')"
@click="$emit('edit', route)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</button>
<button
class="route-action-btn route-action-btn-danger"
:title="t('routeEditor.close')"
@click="$emit('delete', route)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</article>
</template>
@ -108,4 +118,4 @@ function onToggle(event: Event): void {
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
emit("toggle", next);
}
</script>
</script>

View file

@ -6,149 +6,166 @@
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
<div class="editor-heading">
<span class="editor-eyebrow">{{ t("routeEditor.eyebrow") }}</span>
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
</div>
<button class="icon-btn" :title="t('routeEditor.close')" @click="close"></button>
</div>
<form class="editor-body" @submit.prevent="save">
<div v-if="!isEdit" class="field">
<label
>{{ t("routeEditor.templates") }}
<span class="lbl-note">{{ t("routeEditor.templatesNote") }}</span></label
>
<div class="templates">
<button
v-for="tmpl in ROUTE_TEMPLATES"
:key="tmpl.id"
type="button"
class="template-chip"
:class="{ active: form.id === tmpl.id }"
@click="applyTemplate(tmpl)"
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionBasic") }}</h3>
<div v-if="!isEdit" class="field">
<label
>{{ t("routeEditor.templates") }}
<span class="lbl-note">{{ t("routeEditor.templatesNote") }}</span></label
>
{{ t(tmpl.nameKey) }}
</button>
<div class="templates">
<button
v-for="tmpl in ROUTE_TEMPLATES"
:key="tmpl.id"
type="button"
class="template-chip"
:class="{ active: form.id === tmpl.id }"
@click="applyTemplate(tmpl)"
>
{{ t(tmpl.nameKey) }}
</button>
</div>
</div>
</div>
<div class="field">
<label>{{ t("routeEditor.name") }}</label>
<input
v-model="form.name"
type="text"
class="input"
:placeholder="t('routeEditor.namePlaceholder')"
required
/>
</div>
<div class="field">
<label>{{ t("routeEditor.id") }}</label>
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
<div class="hint">{{ t("routeEditor.idHint") }}</div>
</div>
<div class="field inline">
<input v-model="form.enabled" type="checkbox" />
<span>{{ t("routeEditor.enabled") }}</span>
</div>
<div class="field inline">
<input v-model="form.fallback" type="checkbox" />
<span
>{{ t("routeEditor.fallback") }}
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
>
</div>
<div class="field inline">
<input v-model="form.stop" type="checkbox" />
<span
>{{ t("routeEditor.stop") }}
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
>
</div>
<div class="field">
<label
>{{ t("routeEditor.discordRoles") }}
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
>
<input
v-model="form.discordRolesText"
type="text"
class="input"
:placeholder="t('routeEditor.discordRolesPlaceholder')"
/>
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
</div>
<div class="field">
<label
>{{ t("routeEditor.filters") }}
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
>
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
<select v-model="f.type">
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
{{ t("filter." + ft) }}
</option>
</select>
<div class="field">
<label>{{ t("routeEditor.name") }}</label>
<input
v-model="f.matchText"
v-model="form.name"
type="text"
:placeholder="t('routeEditor.matchPlaceholder')"
class="input"
:placeholder="t('routeEditor.namePlaceholder')"
required
/>
<label class="inline">
<input v-model="f.exclude" type="checkbox" /><span>{{ t("routeEditor.not") }}</span>
</label>
<button type="button" class="icon-btn danger" @click="form.filters.splice(i, 1)">
</button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">
{{ t("routeEditor.addFilter") }}
</button>
<div class="err">{{ filterError }}</div>
</div>
<div class="field">
<label
>{{ t("routeEditor.targets") }}
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
>
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
<select v-model="tg.platform">
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="tg.platform === 'discord'">
<input
v-model="tg.channelId"
type="text"
class="tg-in1"
:placeholder="t('routeEditor.channelPlaceholder')"
/>
<input
v-model="tg.threadId"
type="text"
class="tg-in2"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else>
<input
v-model="tg.chatId"
type="text"
class="tg-in1"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="tg.topicId"
type="text"
class="tg-in2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
</button>
<div class="field">
<label>{{ t("routeEditor.id") }}</label>
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
<div class="hint">{{ t("routeEditor.idHint") }}</div>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
{{ t("routeEditor.addTarget") }}
</button>
<div class="err">{{ targetError }}</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionOptions") }}</h3>
<div class="field inline">
<input v-model="form.enabled" type="checkbox" />
<span>{{ t("routeEditor.enabled") }}</span>
</div>
<div class="field inline">
<input v-model="form.fallback" type="checkbox" />
<span
>{{ t("routeEditor.fallback") }}
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
>
</div>
<div class="field inline">
<input v-model="form.stop" type="checkbox" />
<span
>{{ t("routeEditor.stop") }}
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
>
</div>
<div class="field">
<label
>{{ t("routeEditor.discordRoles") }}
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
>
<input
v-model="form.discordRolesText"
type="text"
class="input"
:placeholder="t('routeEditor.discordRolesPlaceholder')"
/>
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionFilters") }}</h3>
<div class="field">
<label
>{{ t("routeEditor.filters") }}
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
>
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
<select v-model="f.type">
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
{{ t("filter." + ft) }}
</option>
</select>
<input
v-model="f.matchText"
type="text"
:placeholder="t('routeEditor.matchPlaceholder')"
/>
<label class="inline">
<input v-model="f.exclude" type="checkbox" /><span
>{{ t("routeEditor.not") }}</span
>
</label>
<button type="button" class="icon-btn danger" @click="form.filters.splice(i, 1)">
</button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">
{{ t("routeEditor.addFilter") }}
</button>
<div class="err">{{ filterError }}</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionTargets") }}</h3>
<div class="field">
<label
>{{ t("routeEditor.targets") }}
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
>
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
<select v-model="tg.platform">
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="tg.platform === 'discord'">
<input
v-model="tg.channelId"
type="text"
class="tg-in1"
:placeholder="t('routeEditor.channelPlaceholder')"
/>
<input
v-model="tg.threadId"
type="text"
class="tg-in2"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else>
<input
v-model="tg.chatId"
type="text"
class="tg-in1"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="tg.topicId"
type="text"
class="tg-in2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
</button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
{{ t("routeEditor.addTarget") }}
</button>
<div class="err">{{ targetError }}</div>
</div>
</section>
<div class="err">{{ formError }}</div>
</form>
<div class="editor-foot">