mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(admin): group-centric console navigation and group-scoped routes API
Add GET/PUT /api/groups/:groupId/routes endpoints that filter and merge routes by group (backend forces groupId from the path param). Rework the console so the top level lists groups; entering a group shows and manages only that group's routes. Drop the route-level group selector and the now-redundant group badge on route cards.
This commit is contained in:
parent
62985a2f3f
commit
d3c1349d00
5 changed files with 327 additions and 186 deletions
|
|
@ -6,35 +6,36 @@
|
|||
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
<span class="route-name">{{ route.name || "(untitled)" }}</span>
|
||||
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
||||
<span class="route-id">{{ route.id }}</span>
|
||||
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
|
||||
<span v-if="groupName" class="badge group">{{ groupName }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn" title="Edit" @click="$emit('edit', route)">✎</button>
|
||||
<button class="icon-btn danger" title="Delete" @click="$emit('delete', route)">✕</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>
|
||||
</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 ? "NOT " : "" }}{{ FILTER_LABELS[f.type] || f.type }}</span>
|
||||
<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">no filters</span></span>
|
||||
<span v-if="!route.filters.length" class="chip"><span class="f-type">{{ t("route.noFilters") }}</span></span>
|
||||
</div>
|
||||
<div class="target">
|
||||
<span><b>CHANNEL</b><code>{{ route.target.channelId }}</code></span>
|
||||
<span v-if="route.target.threadId"><b>THREAD</b><code>{{ route.target.threadId }}</code></span>
|
||||
<span><b>{{ t("route.channel") }}</b><code>{{ route.target.channelId }}</code></span>
|
||||
<span v-if="route.target.threadId"><b>{{ t("route.thread") }}</b><code>{{ route.target.threadId }}</code></span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from "~/types";
|
||||
import { FILTER_LABELS, fmtMatch } from "~/types";
|
||||
import { fmtMatch } from "~/types";
|
||||
|
||||
const props = defineProps<{ route: Route; groupName?: string }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ route: Route }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", route: Route): void;
|
||||
(e: "edit", route: Route): void;
|
||||
|
|
|
|||
|
|
@ -6,68 +6,60 @@
|
|||
<Transition name="slide">
|
||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||
<div class="editor-head">
|
||||
<h2>{{ isEdit ? "Edit route" : "New route" }}</h2>
|
||||
<button class="icon-btn" title="Close" @click="close">✕</button>
|
||||
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
|
||||
<button class="icon-btn" :title="t('routeEditor.close')" @click="close">✕</button>
|
||||
</div>
|
||||
<form class="editor-body" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="form.name" type="text" placeholder="My Route" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Group</label>
|
||||
<select v-model="form.groupId" :disabled="!groups.length">
|
||||
<option value="" disabled>{{ groups.length ? "Select a group" : "No groups yet" }}</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
<div v-if="!groups.length" class="hint">Create a group first (Groups tab).</div>
|
||||
<label>{{ t("routeEditor.name") }}</label>
|
||||
<input v-model="form.name" type="text" :placeholder="t('routeEditor.namePlaceholder')" required />
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>ID</label>
|
||||
<label>{{ t("routeEditor.id") }}</label>
|
||||
<input v-model="form.id" type="text" placeholder="my-route" required />
|
||||
<div class="hint">Unique, use a-z / 0-9 / dashes</div>
|
||||
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<input v-model="form.lang" type="text" placeholder="en" />
|
||||
<div class="hint">en or zh; custom via KV i18n:<lang></div>
|
||||
<label>{{ t("routeEditor.language") }}</label>
|
||||
<input v-model="form.lang" type="text" :placeholder="t('routeEditor.langPlaceholder')" />
|
||||
<div class="hint">{{ t("routeEditor.langHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>Route enabled</span>
|
||||
<span>{{ t("routeEditor.enabled") }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Filters <span class="lbl-note">(all must match · AND)</span></label>
|
||||
<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="t in FILTER_TYPES" :key="t" :value="t">{{ FILTER_LABELS[t] }}</option>
|
||||
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">{{ t("filter." + ft) }}</option>
|
||||
</select>
|
||||
<input v-model="f.matchText" type="text" placeholder="match value" />
|
||||
<label class="inline" title="Invert this filter">
|
||||
<input v-model="f.exclude" type="checkbox" /><span>NOT</span>
|
||||
<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" title="Remove filter" @click="form.filters.splice(i, 1)">✕</button>
|
||||
<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">+ Add filter</button>
|
||||
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">{{ t("routeEditor.addFilter") }}</button>
|
||||
<div class="err">{{ filterError }}</div>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>Channel ID</label>
|
||||
<input v-model="form.channelId" type="text" placeholder="Discord channel ID" required />
|
||||
<label>{{ t("routeEditor.channel") }}</label>
|
||||
<input v-model="form.channelId" type="text" :placeholder="t('routeEditor.channelPlaceholder')" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Thread ID <span class="lbl-note">(optional)</span></label>
|
||||
<input v-model="form.threadId" type="text" placeholder="Optional thread ID" />
|
||||
<label>{{ t("routeEditor.thread") }} <span class="lbl-note">{{ t("routeEditor.threadNote") }}</span></label>
|
||||
<input v-model="form.threadId" type="text" :placeholder="t('routeEditor.threadPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="err">{{ formError }}</div>
|
||||
</form>
|
||||
<div class="editor-foot">
|
||||
<button class="btn btn-ghost" type="button" @click="close">Cancel</button>
|
||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">Save route</button>
|
||||
<button class="btn btn-ghost" type="button" @click="close">{{ t("routeEditor.cancel") }}</button>
|
||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">{{ t("routeEditor.save") }}</button>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
|
|
@ -76,19 +68,20 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
import type { Filter, Route, Group } from "~/types";
|
||||
import { FILTER_TYPES, FILTER_LABELS, fmtMatch } from "~/types";
|
||||
import type { Filter, Route } from "~/types";
|
||||
import { FILTER_TYPES, fmtMatch } from "~/types";
|
||||
|
||||
interface FilterForm extends Filter {
|
||||
matchText: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean; groups: Group[] }>();
|
||||
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "save", route: Route): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const isEdit = computed(() => props.route != null);
|
||||
const filterError = ref("");
|
||||
const formError = ref("");
|
||||
|
|
@ -100,7 +93,6 @@ const form = reactive({
|
|||
enabled: true,
|
||||
channelId: "",
|
||||
threadId: "",
|
||||
groupId: "",
|
||||
filters: [] as FilterForm[],
|
||||
});
|
||||
|
||||
|
|
@ -133,7 +125,6 @@ watch(
|
|||
form.enabled = r?.enabled ?? true;
|
||||
form.channelId = r?.target.channelId ?? "";
|
||||
form.threadId = r?.target.threadId ?? "";
|
||||
form.groupId = r?.groupId ?? (props.groups.length === 1 ? props.groups[0]!.id : "");
|
||||
form.filters = (r && r.filters.length
|
||||
? r.filters
|
||||
: [{ type: "event", match: "", exclude: false }]
|
||||
|
|
@ -153,14 +144,14 @@ function collect(): Route | null {
|
|||
const f = form.filters[i]!;
|
||||
const match = parseMatch(f.matchText);
|
||||
if (!match) {
|
||||
filterError.value = `Filter ${i + 1} needs a match value`;
|
||||
filterError.value = t("routeEditor.errFilterMatch", { n: i + 1 });
|
||||
return null;
|
||||
}
|
||||
filters.push({ type: f.type, match, exclude: f.exclude });
|
||||
}
|
||||
filterError.value = "";
|
||||
if (!filters.length) {
|
||||
filterError.value = "Add at least one filter";
|
||||
filterError.value = t("routeEditor.errAddFilter");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
|
@ -168,7 +159,6 @@ function collect(): Route | null {
|
|||
name: form.name.trim(),
|
||||
enabled: form.enabled,
|
||||
lang: form.lang.trim() || undefined,
|
||||
groupId: form.groupId || undefined,
|
||||
filters,
|
||||
target: {
|
||||
channelId: form.channelId.trim(),
|
||||
|
|
@ -182,19 +172,15 @@ function save(): void {
|
|||
const route = collect();
|
||||
if (!route) return;
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
|
||||
formError.value = "ID must be a-z / 0-9 / dashes";
|
||||
formError.value = t("routeEditor.errIdFormat");
|
||||
return;
|
||||
}
|
||||
if (!route.name) {
|
||||
formError.value = "Name is required";
|
||||
formError.value = t("routeEditor.errName");
|
||||
return;
|
||||
}
|
||||
if (!route.target.channelId) {
|
||||
formError.value = "Channel ID is required";
|
||||
return;
|
||||
}
|
||||
if (!route.groupId) {
|
||||
formError.value = "Please select a group";
|
||||
formError.value = t("routeEditor.errChannel");
|
||||
return;
|
||||
}
|
||||
emit("save", route);
|
||||
|
|
|
|||
51
admin/composables/useGroupRoutes.ts
Normal file
51
admin/composables/useGroupRoutes.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Route } from "~/types";
|
||||
|
||||
export function useGroupRoutesApi() {
|
||||
const routes = ref<Route[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(groupId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/routes`, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { routes?: Route[] };
|
||||
routes.value = data.routes ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(groupId: string, next: Route[]): Promise<void> {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/routes`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ routes: next }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
routes.value = next;
|
||||
}
|
||||
|
||||
return { routes, loading, needLogin, error, load, save };
|
||||
}
|
||||
|
|
@ -5,74 +5,57 @@
|
|||
<div class="brand-mark">WH</div>
|
||||
<div>
|
||||
<h1>WebHooker</h1>
|
||||
<span class="tagline">CONFIG CONSOLE</span>
|
||||
<span class="tagline">{{ t("app.tagline") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button v-if="!needLogin && view === 'routes'" class="btn btn-accent" @click="openNew">
|
||||
+ New Route
|
||||
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
|
||||
<button v-if="!needLogin && selectedGroup" class="btn btn-accent" @click="openNew">
|
||||
{{ t("app.newRoute") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!needLogin && view === 'groups' && isSuper"
|
||||
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
|
||||
class="btn btn-accent"
|
||||
@click="openNewGroup"
|
||||
>
|
||||
+ New Group
|
||||
{{ t("app.newGroup") }}
|
||||
</button>
|
||||
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">Sign out</a>
|
||||
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">{{ t("app.signOut") }}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div v-if="needLogin" class="login">
|
||||
<div class="login-card">
|
||||
<h2>Config Console</h2>
|
||||
<p v-if="forbidden">
|
||||
Access denied — this GitHub account is not an admin or group admin.
|
||||
</p>
|
||||
<p v-else>Sign in with GitHub to manage routes.</p>
|
||||
<a class="btn btn-accent btn-lg" href="/admin/login">Sign in with GitHub</a>
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs">
|
||||
<button class="tab" :class="{ active: view === 'routes' }" @click="switchView('routes')">
|
||||
Routes
|
||||
</button>
|
||||
<button
|
||||
v-if="isSuper"
|
||||
class="tab"
|
||||
:class="{ active: view === 'groups' }"
|
||||
@click="switchView('groups')"
|
||||
>
|
||||
Groups
|
||||
</button>
|
||||
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
|
||||
Send Logs
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<template v-if="view === 'routes'">
|
||||
<!-- Group detail view -->
|
||||
<template v-if="selectedGroup">
|
||||
<section class="toolbar">
|
||||
<div>
|
||||
<span class="kpi-label">ROUTES</span>
|
||||
<span class="kpi">{{ routes.length }}</span>
|
||||
<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="loading ? '' : 'ok'"></span>
|
||||
<span>{{ loading ? "loading" : "connected" }}</span>
|
||||
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
|
||||
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
|
||||
|
||||
<section class="routes">
|
||||
<RouteCard
|
||||
v-for="(r, i) in routes"
|
||||
v-for="(r, i) in groupRoutes"
|
||||
:key="r.id"
|
||||
:route="r"
|
||||
:group-name="groupName(r.groupId)"
|
||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||
@toggle="onToggle"
|
||||
@edit="openEdit"
|
||||
|
|
@ -80,65 +63,94 @@
|
|||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && !routes.length" class="empty">
|
||||
<p>No routes configured yet.</p>
|
||||
<button class="btn btn-accent" @click="openNew">Create your first route</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view === 'groups'">
|
||||
<section class="toolbar">
|
||||
<div>
|
||||
<span class="kpi-label">GROUPS</span>
|
||||
<span class="kpi">{{ groups.length }}</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="dot" :class="groupsLoading ? '' : 'ok'"></span>
|
||||
<span>{{ groupsLoading ? "loading" : "connected" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="groupsError" class="err">{{ groupsError }}</p>
|
||||
|
||||
<section class="routes">
|
||||
<article v-for="g in groups" :key="g.id" class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<span class="route-name">{{ g.name || "(untitled)" }}</span>
|
||||
<span class="route-id">{{ g.id }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn" title="Edit" @click="openEditGroup(g)">✎</button>
|
||||
<button class="icon-btn danger" title="Delete" @click="onDeleteGroup(g)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="target">
|
||||
<span
|
||||
><b>ADMINS</b
|
||||
><code>{{ g.adminIds.length ? g.adminIds.join(", ") : "—" }}</code></span
|
||||
>
|
||||
<span
|
||||
><b>OWNERS</b
|
||||
><code>{{ g.owners && g.owners.length ? g.owners.join(", ") : "any" }}</code></span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="!groupsLoading && !groups.length" class="empty">
|
||||
<p>No groups yet. Groups scope routes by org/user and delegate access.</p>
|
||||
<button class="btn btn-accent" @click="openNewGroup">Create your first group</button>
|
||||
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
|
||||
<p>{{ t("routes.emptyGroup") }}</p>
|
||||
<button class="btn btn-accent" @click="openNew">{{ t("routes.createFirst") }}</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Top-level views -->
|
||||
<template v-else>
|
||||
<section class="toolbar">
|
||||
<div class="status">
|
||||
<span class="dot" :class="logsLoading ? '' : 'ok'"></span>
|
||||
<span>{{ logsLoading ? "loading" : "connected" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<SendLogs :logs="logs" :loading="logsLoading" @refresh="loadLogs" />
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<template v-if="view === 'groups'">
|
||||
<section class="toolbar">
|
||||
<div>
|
||||
<span class="kpi-label">{{ t("kpi.groups") }}</span>
|
||||
<span class="kpi">{{ groups.length }}</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="dot" :class="groupsLoading ? '' : 'ok'"></span>
|
||||
<span>{{ groupsLoading ? t("status.loading") : t("status.connected") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="groupsError" class="err">{{ groupsError }}</p>
|
||||
|
||||
<section class="routes">
|
||||
<article
|
||||
v-for="(g, i) in groups"
|
||||
:key="g.id"
|
||||
class="card group-card"
|
||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||
@click="enterGroup(g)"
|
||||
>
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<span class="route-name">{{ g.name || t("route.untitled") }}</span>
|
||||
<span class="route-id">{{ g.id }}</span>
|
||||
</div>
|
||||
<div v-if="isSuper" class="card-actions" @click.stop>
|
||||
<button class="icon-btn" :title="t('groupEditor.editTitle')" @click="openEditGroup(g)">
|
||||
✎
|
||||
</button>
|
||||
<button class="icon-btn danger" @click="onDeleteGroup(g)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="target">
|
||||
<span
|
||||
><b>{{ t("groups.admins") }}</b
|
||||
><code>{{ g.adminIds.length ? g.adminIds.join(", ") : "—" }}</code></span
|
||||
>
|
||||
<span
|
||||
><b>{{ t("groups.owners") }}</b
|
||||
><code>{{
|
||||
g.owners && g.owners.length ? g.owners.join(", ") : t("groups.any")
|
||||
}}</code></span
|
||||
>
|
||||
</div>
|
||||
<div class="group-open">{{ t("groups.open") }}</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="!groupsLoading && !groups.length" class="empty">
|
||||
<p>{{ t("groups.empty") }}</p>
|
||||
<button v-if="isSuper" class="btn btn-accent" @click="openNewGroup">
|
||||
{{ t("groups.createFirst") }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="toolbar">
|
||||
<div class="status">
|
||||
<span class="dot" :class="logsLoading ? '' : 'ok'"></span>
|
||||
<span>{{ logsLoading ? t("status.loading") : t("status.connected") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<SendLogs :logs="logs" :loading="logsLoading" @refresh="loadLogs" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</main>
|
||||
|
|
@ -147,7 +159,6 @@
|
|||
:open="editorOpen"
|
||||
:route="editing"
|
||||
:saving="saving"
|
||||
:groups="groups"
|
||||
@close="editorOpen = false"
|
||||
@save="onSave"
|
||||
/>
|
||||
|
|
@ -165,23 +176,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { Group, Route } from "~/types";
|
||||
|
||||
const { routes, loading, needLogin, error, load, save } = useRoutesApi();
|
||||
const { t, toggle } = useI18n();
|
||||
const { push } = useToasts();
|
||||
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
|
||||
const {
|
||||
groups,
|
||||
isSuper,
|
||||
loading: groupsLoading,
|
||||
needLogin,
|
||||
error: groupsError,
|
||||
load: loadGroups,
|
||||
save: saveGroups,
|
||||
} = useGroupsApi();
|
||||
const {
|
||||
routes: groupRoutes,
|
||||
loading: groupRoutesLoading,
|
||||
error: groupRoutesError,
|
||||
load: loadGroupRoutes,
|
||||
save: saveGroupRoutes,
|
||||
} = useGroupRoutesApi();
|
||||
|
||||
const editorOpen = ref(false);
|
||||
const editing = ref<Route | null>(null);
|
||||
const saving = ref(false);
|
||||
const forbidden = ref(false);
|
||||
const view = ref<"routes" | "groups" | "logs">("routes");
|
||||
const view = ref<"groups" | "logs">("groups");
|
||||
const selectedGroup = ref<Group | null>(null);
|
||||
|
||||
const groupEditorOpen = ref(false);
|
||||
const editingGroup = ref<Group | null>(null);
|
||||
|
|
@ -192,26 +212,26 @@ onMounted(() => {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
forbidden.value = params.get("error") === "forbidden";
|
||||
}
|
||||
load();
|
||||
loadLogs();
|
||||
loadGroups();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
function groupName(id?: string): string | undefined {
|
||||
if (!id) return undefined;
|
||||
return groups.value.find((g) => g.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function switchView(next: "routes" | "groups" | "logs"): void {
|
||||
function switchView(next: "groups" | "logs"): void {
|
||||
view.value = next;
|
||||
if (next === "routes") {
|
||||
load();
|
||||
loadGroups();
|
||||
}
|
||||
if (next === "groups") loadGroups();
|
||||
if (next === "logs") loadLogs();
|
||||
}
|
||||
|
||||
function enterGroup(group: Group): void {
|
||||
selectedGroup.value = group;
|
||||
loadGroupRoutes(group.id);
|
||||
}
|
||||
|
||||
function exitGroup(): void {
|
||||
selectedGroup.value = null;
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
function openNew(): void {
|
||||
editing.value = null;
|
||||
editorOpen.value = true;
|
||||
|
|
@ -223,44 +243,56 @@ function openEdit(route: Route): void {
|
|||
}
|
||||
|
||||
async function onSave(route: Route): Promise<void> {
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
let next: Route[];
|
||||
if (editing.value) {
|
||||
next = routes.value.map((r) => (r.id === editing.value!.id ? route : r));
|
||||
next = groupRoutes.value.map((r) => (r.id === editing.value!.id ? route : r));
|
||||
} else {
|
||||
if (routes.value.some((r) => r.id === route.id)) {
|
||||
push("Route ID already exists", "bad");
|
||||
if (groupRoutes.value.some((r) => r.id === route.id)) {
|
||||
push(t("toast.routeIdExists"), "bad");
|
||||
return;
|
||||
}
|
||||
next = [...routes.value, route];
|
||||
next = [...groupRoutes.value, route];
|
||||
}
|
||||
await save(next);
|
||||
await saveGroupRoutes(group.id, next);
|
||||
editorOpen.value = false;
|
||||
push("Routes saved");
|
||||
push(t("toast.routesSaved"));
|
||||
} catch (err) {
|
||||
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(route: Route): Promise<void> {
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
try {
|
||||
await save(routes.value.map((r) => (r.id === route.id ? route : r)));
|
||||
await saveGroupRoutes(
|
||||
group.id,
|
||||
groupRoutes.value.map((r) => (r.id === route.id ? route : r)),
|
||||
);
|
||||
} catch (err) {
|
||||
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
load();
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
loadGroupRoutes(group.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(route: Route): Promise<void> {
|
||||
if (!window.confirm(`Delete route "${route.name || route.id}"?`)) return;
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
if (!window.confirm(t("confirm.deleteRoute", { name: route.name || route.id }))) return;
|
||||
try {
|
||||
await save(routes.value.filter((r) => r.id !== route.id));
|
||||
push("Route deleted");
|
||||
await saveGroupRoutes(
|
||||
group.id,
|
||||
groupRoutes.value.filter((r) => r.id !== route.id),
|
||||
);
|
||||
push(t("toast.routeDeleted"));
|
||||
} catch (err) {
|
||||
push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
push(t("toast.deleteFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,30 +314,30 @@ async function onSaveGroup(group: Group): Promise<void> {
|
|||
next = groups.value.map((g) => (g.id === editingGroup.value!.id ? group : g));
|
||||
} else {
|
||||
if (groups.value.some((g) => g.id === group.id)) {
|
||||
push("Group ID already exists", "bad");
|
||||
push(t("toast.groupIdExists"), "bad");
|
||||
return;
|
||||
}
|
||||
next = [...groups.value, group];
|
||||
}
|
||||
await saveGroups(next);
|
||||
groupEditorOpen.value = false;
|
||||
push("Groups saved");
|
||||
push(t("toast.groupSaved"));
|
||||
} catch (err) {
|
||||
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
} finally {
|
||||
savingGroup.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteGroup(group: Group): Promise<void> {
|
||||
const used = routes.value.filter((r) => r.groupId === group.id).length;
|
||||
const warn = used ? ` ${used} route(s) reference it and will lose their group.` : "";
|
||||
if (!window.confirm(`Delete group "${group.name || group.id}"?${warn}`)) return;
|
||||
const used = groupRoutes.value.filter((r) => r.groupId === group.id).length;
|
||||
const warn = used ? t("confirm.deleteGroupWarn", { n: used }) : "";
|
||||
if (!window.confirm(t("confirm.deleteGroup", { name: group.name || group.id }) + warn)) return;
|
||||
try {
|
||||
await saveGroups(groups.value.filter((g) => g.id !== group.id));
|
||||
push("Group deleted");
|
||||
push(t("toast.groupDeleted"));
|
||||
} catch (err) {
|
||||
push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad");
|
||||
push(t("toast.deleteFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue