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:
RhenCloud 2026-08-02 08:39:46 +08:00
parent 62985a2f3f
commit d3c1349d00
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
5 changed files with 327 additions and 186 deletions

View file

@ -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:&lt;lang&gt;</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);