feat(filters): JSONPath field filters, operators, AST groups, fragments, and test-match

Add a field filter type reading any payload value by JSONPath with array expansion, 12 comparison operators (eq/ne/contains/startsWith/endsWith/regex/gt/gte/lt/lte/in/exists), a visual AST builder (all/any/not) in the route editor, chip-based multi-value input, a stateless POST /admin/api/test-match dry-run, and named filter fragments stored in D1 (d1_fragments, migration 0010) inlined into route ASTs on insert.
This commit is contained in:
RhenCloud 2026-08-18 12:19:08 +08:00
parent c955db03c3
commit c090281cb2
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
30 changed files with 1793 additions and 422 deletions

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { DeliveryMetrics, Group, Route, SendRecord } from "~/types";
import { collectEvents } from "~/composables/useFilterNode";
const props = defineProps<{
groups: Group[];
@ -107,9 +108,7 @@ function platformLabel(p?: string): string {
}
function routeEvents(r: Route): string[] {
return r.filters
.filter((f) => f.type === "event")
.flatMap((f) => (Array.isArray(f.match) ? f.match : [f.match]));
return collectEvents(r.ast ?? { all: r.filters });
}
</script>

View file

@ -298,6 +298,7 @@
:open="editorOpen"
:route="editing"
:saving="saving"
:group-id="selectedGroup?.id ?? null"
@close="editorOpen = false"
@save="onSave"
/>

View file

@ -0,0 +1,141 @@
<script setup lang="ts">
import type { NodeForm } from "~/composables/useFilterNode";
import { blankNode } from "~/composables/useFilterNode";
import { FILTER_TYPES, FILTER_OPS } from "~/types";
defineOptions({ name: "FilterNodeEditor" });
const props = withDefaults(
defineProps<{ node: NodeForm; depth?: number; deletable?: boolean }>(),
{ depth: 0, deletable: false },
);
const emit = defineEmits<{ (e: "remove"): void }>();
const { t } = useI18n();
const NODE_KINDS = ["all", "any"] as const;
function addChild(kind: "leaf" | "all" | "any" | "not"): void {
props.node.children.push(blankNode(kind));
}
function unwrapNot(): void {
const child = props.node.child;
if (!child) return;
props.node.kind = child.kind;
props.node.leaf = child.leaf;
props.node.children = child.children;
props.node.child = child.child;
}
</script>
<template>
<div class="node-editor" :class="{ 'node-nested': depth > 0 }">
<div v-if="node.kind === 'leaf'" class="leaf-row">
<select v-model="node.leaf.type" class="node-select">
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">{{ t("filter." + ft) }}</option>
</select>
<input
v-if="node.leaf.type === 'field'"
v-model="node.leaf.path"
class="input node-path"
:placeholder="t('routeEditor.pathPlaceholder')"
/>
<select
v-if="node.leaf.type !== 'keyword'"
v-model="node.leaf.op"
class="node-select"
:title="t('routeEditor.op')"
>
<option v-for="op in FILTER_OPS" :key="op" :value="op">{{ t("filterOp." + op) }}</option>
</select>
<TagInput
v-if="node.leaf.type === 'keyword' || node.leaf.op !== 'exists'"
v-model="node.leaf.values"
class="node-values"
:placeholder="t('routeEditor.valuesPlaceholder')"
/>
<label class="inline node-exclude" :title="t('routeEditor.not')">
<input v-model="node.leaf.exclude" type="checkbox" />
<span>{{ t("routeEditor.not") }}</span>
</label>
<button
v-if="deletable"
type="button"
class="icon-btn danger"
:title="t('routeEditor.remove')"
@click="emit('remove')"
>
</button>
</div>
<div v-else-if="node.kind === 'all' || node.kind === 'any'" class="node-group">
<div class="node-group-head">
<select v-model="node.kind" class="node-combo">
<option v-for="k in NODE_KINDS" :key="k" :value="k">{{ t("filterNode." + k) }}</option>
</select>
<div class="node-actions">
<button type="button" class="btn btn-ghost node-add" @click="addChild('leaf')">
+ {{ t("routeEditor.addLeaf") }}
</button>
<button type="button" class="btn btn-ghost node-add" @click="addChild('all')">
+ {{ t("routeEditor.addGroup") }}
</button>
<button type="button" class="btn btn-ghost node-add" @click="addChild('not')">
+ {{ t("routeEditor.addNot") }}
</button>
<button
v-if="deletable"
type="button"
class="icon-btn danger"
:title="t('routeEditor.remove')"
@click="emit('remove')"
>
</button>
</div>
</div>
<div class="node-children">
<FilterNodeEditor
v-for="(child, i) in node.children"
:key="i"
:node="child"
:depth="depth + 1"
deletable
@remove="node.children.splice(i, 1)"
/>
<div v-if="node.children.length === 0" class="node-empty">{{ t("filterNode.empty") }}</div>
</div>
</div>
<div v-else class="node-group node-not">
<div class="node-group-head">
<span class="node-combo node-combo-label">{{ t("filterNode.not") }}</span>
<div class="node-actions">
<button type="button" class="btn btn-ghost node-add" @click="unwrapNot">
{{ t("filterNode.unwrap") }}
</button>
<button
v-if="deletable"
type="button"
class="icon-btn danger"
:title="t('routeEditor.remove')"
@click="emit('remove')"
>
</button>
</div>
</div>
<div class="node-children">
<FilterNodeEditor v-if="node.child" :node="node.child" :depth="depth + 1" />
</div>
</div>
</div>
</template>

View file

@ -35,18 +35,10 @@
</div>
<div class="route-card-filters">
<span
v-for="(f, i) in route.filters"
:key="i"
class="route-chip"
:class="{ exclude: f.exclude }"
>
<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 v-if="summary" class="route-chip">
<span class="route-chip-val">{{ summary }}</span>
</span>
<span v-if="!route.filters.length" class="route-chip route-chip-empty">
<span v-else class="route-chip route-chip-empty">
<span class="route-chip-type">{{ t("route.noFilters") }}</span>
</span>
</div>
@ -151,8 +143,8 @@
</template>
<script setup lang="ts">
import type { Route } from "~/types";
import { fmtMatch } from "~/types";
import type { FilterNode, Route } from "~/types";
import { describeNode } from "~/composables/useFilterNode";
const { t } = useI18n();
@ -162,6 +154,11 @@ const props = defineProps<{
atLast?: boolean;
readonly?: boolean;
}>();
const summary = computed(() => {
const node: FilterNode = props.route.ast ?? { all: props.route.filters };
return describeNode(node, t);
});
const emit = defineEmits<{
(e: "toggle", route: Route): void;
(e: "edit", route: Route): void;

View file

@ -1,220 +1,39 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="overlay" @click.self="close"></div>
</Transition>
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<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">
<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
>
<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 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>
</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">
<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>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { Filter, Route, RouteTarget } from "~/types";
import { FILTER_TYPES, ROUTE_TEMPLATES, fmtMatch } from "~/types";
import { computed, reactive, ref, watch } from "vue";
import type { Filter, FilterNode, NamedFragment, Route, RouteTarget, RouteTemplate } from "~/types";
import { ROUTE_TEMPLATES } from "~/types";
import type { NodeForm } from "~/composables/useFilterNode";
import { blankLeafForm, blankNode, nodeToForm, nodeFormToRouteFilters, formToNode } from "~/composables/useFilterNode";
interface FilterForm extends Filter {
matchText: string;
interface TargetForm {
platform: "discord" | "telegram";
channelId: string;
threadId: string;
chatId: string;
topicId: string;
}
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean }>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", route: Route): void;
}>();
const props = withDefaults(
defineProps<{ open: boolean; route: Route | null; saving: boolean; groupId?: string | null }>(),
{ groupId: null },
);
const emit = defineEmits<{ (e: "close"): void; (e: "save", route: Route): void }>();
const { t } = useI18n();
const isEdit = computed(() => props.route != null);
const filterError = ref("");
const targetError = ref("");
const formError = ref("");
interface TargetForm extends RouteTarget {
platform: "discord" | "telegram";
}
function blankTarget(): TargetForm {
return {
platform: "discord",
channelId: "",
threadId: "",
chatId: "",
topicId: "",
};
}
const blankTarget = (): TargetForm => ({
platform: "discord",
channelId: "",
threadId: "",
chatId: "",
topicId: "",
});
const form = reactive({
id: "",
@ -224,34 +43,206 @@ const form = reactive({
stop: false,
discordRolesText: "",
targets: [] as TargetForm[],
filters: [] as FilterForm[],
});
function blankFilter(): FilterForm {
return { type: "event", match: "", exclude: false, matchText: "" };
}
const root = ref<NodeForm>(blankNode("all"));
function addFilter(): void {
form.filters.push(blankFilter());
filterError.value = "";
}
const { fragments, load: loadFragments, save: saveFragments } = useFragmentsApi();
const fragmentName = ref("");
const fragmentError = ref("");
function applyTemplate(tmpl: (typeof ROUTE_TEMPLATES)[number]): void {
const testPayload = ref("");
const testEvent = ref("");
const testResult = ref<{ matched: boolean; explanation: string } | null>(null);
const testError = ref("");
const testing = ref(false);
function applyTemplate(tmpl: RouteTemplate): void {
form.id = tmpl.id;
form.name = t(tmpl.nameKey);
form.filters = tmpl.filters.map((f) => ({
...f,
matchText: fmtMatch(f.match),
})) as FilterForm[];
form.targets = [blankTarget()];
filterError.value = "";
targetError.value = "";
formError.value = "";
root.value = tmpl.filters.length ? nodeToForm({ all: tmpl.filters }) : blankNode("all");
}
function addTarget(): void {
form.targets.push(blankTarget());
}
function validateNode(nf: NodeForm): string | null {
if (nf.kind === "leaf") {
if (nf.leaf.type === "field" && !nf.leaf.path.trim()) return t("routeEditor.errPath");
if (nf.leaf.op !== "exists" && nf.leaf.values.every((v) => !v.trim()))
return t("routeEditor.errValues");
return null;
}
if (nf.kind === "all" || nf.kind === "any") {
if (nf.children.length === 0) return t("routeEditor.errGroupEmpty");
for (const c of nf.children) {
const err = validateNode(c);
if (err) return err;
}
return null;
}
if (nf.child) return validateNode(nf.child);
return t("routeEditor.errGroupEmpty");
}
function collect(): Route | null {
filterError.value = "";
targetError.value = "";
formError.value = "";
let filters: Filter[] = [];
let ast: FilterNode | undefined;
if (!form.fallback) {
const err = validateNode(root.value);
if (err) {
filterError.value = err;
return null;
}
const res = nodeFormToRouteFilters(root.value);
filters = res.filters;
ast = res.ast;
}
const targets: RouteTarget[] = [];
for (const tg of form.targets) {
if (tg.platform === "telegram") {
const chatId = tg.chatId.trim();
if (!chatId) continue;
targets.push({ platform: "telegram", chatId, topicId: tg.topicId.trim() || undefined });
} else {
const channelId = tg.channelId.trim();
if (!channelId) continue;
targets.push({ platform: "discord", channelId, threadId: tg.threadId.trim() || undefined });
}
}
const discordRoles = form.discordRolesText
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
id: form.id.trim(),
name: form.name.trim(),
enabled: form.enabled,
fallback: form.fallback || undefined,
stop: form.stop || undefined,
discordRoleIds: discordRoles.length ? discordRoles : undefined,
filters,
...(ast ? { ast } : {}),
targets,
};
}
function save(): void {
const route = collect();
if (!route) return;
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
formError.value = t("routeEditor.errIdFormat");
return;
}
if (!route.name) {
formError.value = t("routeEditor.errName");
return;
}
if (!route.targets.length) {
targetError.value = t("routeEditor.errTargets");
return;
}
form.targets.forEach((tg, i) => {
if (tg.platform === "telegram" && !tg.chatId.trim()) {
targetError.value = t("routeEditor.errChat", { n: i + 1 });
} else if (tg.platform === "discord" && !tg.channelId.trim()) {
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
}
});
if (targetError.value) return;
emit("save", route);
}
function close(): void {
emit("close");
}
async function runTest(): Promise<void> {
testError.value = "";
testResult.value = null;
let payload: unknown;
try {
payload = JSON.parse(testPayload.value);
} catch {
testError.value = t("routeEditor.testInvalidJson");
return;
}
const node = formToNode(root.value);
if (!node) {
testError.value = t("routeEditor.errAddFilter");
return;
}
testing.value = true;
try {
testResult.value = await apiFetch<{ matched: boolean; explanation: string }>(
"/admin/api/test-match",
{
method: "POST",
body: JSON.stringify({ node, event: testEvent.value.trim() || undefined, payload }),
},
);
} catch (err) {
testError.value = err instanceof Error ? err.message : String(err);
} finally {
testing.value = false;
}
}
function insertFragment(frag: NamedFragment): void {
const child = nodeToForm(frag.node);
if (root.value.kind === "all" || root.value.kind === "any") {
root.value.children.push(child);
} else {
root.value = {
kind: "all",
leaf: blankLeafForm(),
children: [root.value, child],
child: null,
};
}
}
async function saveAsFragment(): Promise<void> {
fragmentError.value = "";
const name = fragmentName.value.trim();
if (!name) {
fragmentError.value = t("routeEditor.fragmentsNameRequired");
return;
}
if (!props.groupId) return;
const node = formToNode(root.value);
if (!node) {
fragmentError.value = t("routeEditor.errAddFilter");
return;
}
const id = `frag-${Math.random().toString(36).slice(2, 10)}`;
const next: NamedFragment[] = [
...fragments.value.filter((f) => f.id !== id),
{ id, groupId: props.groupId, name, node },
];
try {
await saveFragments(props.groupId, next);
fragmentName.value = "";
} catch (err) {
fragmentError.value = err instanceof Error ? err.message : String(err);
}
}
function deleteFragment(frag: NamedFragment): void {
if (!props.groupId) return;
const next = fragments.value.filter((f) => f.id !== frag.id);
saveFragments(props.groupId, next).catch((err) => {
fragmentError.value = err instanceof Error ? err.message : String(err);
});
}
watch(
@ -267,110 +258,245 @@ watch(
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
form.targets =
r && r.targets.length
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
? r.targets.map((tg) => ({
...blankTarget(),
...tg,
platform: tg.platform === "telegram" ? "telegram" : "discord",
}))
: [blankTarget()];
form.filters = (
r && r.filters.length
? r.filters
: form.fallback
? []
: [{ type: "event", match: "", exclude: false }]
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
if (r?.ast) {
root.value = nodeToForm(r.ast);
} else if (r && r.filters.length) {
root.value = nodeToForm({ all: r.filters });
} else {
root.value = blankNode("all");
}
filterError.value = "";
targetError.value = "";
formError.value = "";
testPayload.value = "";
testEvent.value = "";
testResult.value = null;
testError.value = "";
fragmentName.value = "";
fragmentError.value = "";
if (props.groupId) loadFragments(props.groupId);
},
);
watch(
() => form.fallback,
(v) => {
if (v && form.filters.every((f) => f.matchText.trim() === "")) {
form.filters = [];
filterError.value = "";
}
},
);
function close(): void {
emit("close");
}
function collect(): Route | null {
const filters: Filter[] = [];
for (let i = 0; i < form.filters.length; i++) {
const f = form.filters[i]!;
const match = parseMatch(f.matchText);
if (!match) {
filterError.value = t("routeEditor.errFilterMatch", { n: i + 1 });
return null;
}
filters.push({ type: f.type, match, exclude: f.exclude });
}
filterError.value = "";
if (!form.fallback && !filters.length) {
filterError.value = t("routeEditor.errAddFilter");
return null;
}
const targets: RouteTarget[] = [];
for (let i = 0; i < form.targets.length; i++) {
const tg = form.targets[i]!;
targets.push({
platform: tg.platform,
channelId: (tg.channelId ?? "").trim() || undefined,
threadId: (tg.threadId ?? "").trim() || undefined,
chatId: (tg.chatId ?? "").trim() || undefined,
topicId: (tg.topicId ?? "").trim() || undefined,
});
}
targetError.value = "";
const discordRoles = form.discordRolesText
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
id: form.id.trim(),
name: form.name.trim(),
enabled: form.enabled,
fallback: form.fallback || undefined,
stop: form.stop || undefined,
discordRoleIds: discordRoles.length ? discordRoles : undefined,
filters,
targets,
};
}
function save(): void {
formError.value = "";
const route = collect();
if (!route) return;
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
formError.value = t("routeEditor.errIdFormat");
return;
}
if (!route.name) {
formError.value = t("routeEditor.errName");
return;
}
if (!route.targets.length) {
targetError.value = t("routeEditor.errTargets");
return;
}
for (let i = 0; i < route.targets.length; i++) {
const tg = route.targets[i]!;
if (tg.platform === "telegram") {
if (!tg.chatId) {
targetError.value = t("routeEditor.errChat", { n: i + 1 });
return;
}
} else if (!tg.channelId) {
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
return;
}
}
emit("save", route);
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="overlay" @click.self="close" />
</Transition>
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<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">
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionBasic") }}</h3>
<div v-if="!isEdit" 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 class="field">
<label>{{ t("routeEditor.name") }}</label>
<input
v-model="form.name"
class="input"
:placeholder="t('routeEditor.namePlaceholder')"
required
/>
</div>
<div class="field">
<label>{{ t("routeEditor.id") }}</label>
<input v-model="form.id" class="input" placeholder="my-route" required />
<div class="hint">{{ t("routeEditor.idHint") }}</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") }}</label>
<input
v-model="form.discordRolesText"
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") }}</label>
</div>
<div class="field">
<FilterNodeEditor v-if="!form.fallback" :node="root" />
<p v-else class="hint">{{ t("routeEditor.fallbackHint") }}</p>
</div>
<div v-if="filterError" class="err">{{ filterError }}</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.testMatch") }}</h3>
<div class="field">
<input
v-model="testEvent"
class="input"
:placeholder="t('routeEditor.testEvent')"
/>
</div>
<div class="field">
<textarea
v-model="testPayload"
class="input test-payload"
rows="6"
:placeholder="t('routeEditor.testPayloadPlaceholder')"
/>
</div>
<div class="field">
<button type="button" class="btn btn-ghost" :disabled="testing" @click="runTest">
{{ testing ? "…" : t("routeEditor.testRun") }}
</button>
</div>
<div v-if="testResult" class="test-result" :class="{ ok: testResult.matched }">
<span class="test-badge">
{{ testResult.matched ? t("routeEditor.testMatched") : t("routeEditor.testNotMatched") }}
</span>
<span class="test-explanation">{{ testResult.explanation }}</span>
</div>
<div v-if="testError" class="err">{{ testError }}</div>
</section>
<section v-if="groupId" class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.fragments") }}</h3>
<div class="fragments-list">
<div v-if="!fragments.length" class="hint">{{ t("routeEditor.fragmentsEmpty") }}</div>
<div v-for="frag in fragments" :key="frag.id" class="fragment-row">
<span class="fragment-name">{{ frag.name }}</span>
<button
type="button"
class="btn btn-ghost fragment-action"
@click="insertFragment(frag)"
>
{{ t("routeEditor.fragmentsInsert") }}
</button>
<button
type="button"
class="icon-btn danger"
:title="t('routeEditor.fragmentsDelete')"
@click="deleteFragment(frag)"
>
</button>
</div>
</div>
<div class="field fragment-save">
<input
v-model="fragmentName"
class="input"
:placeholder="t('routeEditor.fragmentsNamePlaceholder')"
/>
<button type="button" class="btn btn-ghost" @click="saveAsFragment">
{{ t("routeEditor.fragmentsSave") }}
</button>
</div>
<div v-if="fragmentError" class="err">{{ fragmentError }}</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("routeEditor.sectionTargets") }}</h3>
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
<select v-model="tg.platform" class="tg-select">
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="tg.platform === 'discord'">
<input
v-model="tg.channelId"
class="input tg-in1"
:placeholder="t('routeEditor.channelPlaceholder')"
/>
<input
v-model="tg.threadId"
class="input tg-in2"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else>
<input
v-model="tg.chatId"
class="input tg-in1"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="tg.topicId"
class="input tg-in2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<button
type="button"
class="icon-btn danger tg-del"
:title="t('routeEditor.remove')"
@click="form.targets.splice(i, 1)"
>
</button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
{{ t("routeEditor.addTarget") }}
</button>
<div v-if="targetError" class="err">{{ targetError }}</div>
</section>
<div v-if="formError" class="err">{{ formError }}</div>
</form>
<div class="editor-foot">
<button class="btn btn-ghost" @click="close">{{ t("routeEditor.cancel") }}</button>
<button class="btn btn-accent" :disabled="saving" @click="save">
{{ t("routeEditor.save") }}
</button>
</div>
</aside>
</Transition>
</Teleport>
</template>

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps<{ modelValue: string[]; placeholder?: string }>();
const emit = defineEmits<{ (e: "update:modelValue", value: string[]): void }>();
const text = ref("");
function add(value: string) {
const v = value.trim();
if (!v) return;
if (props.modelValue.includes(v)) return;
emit("update:modelValue", [...props.modelValue, v]);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
add(text.value);
text.value = "";
} else if (e.key === "Backspace" && text.value === "" && props.modelValue.length > 0) {
emit("update:modelValue", props.modelValue.slice(0, -1));
}
}
function onBlur() {
if (text.value) {
add(text.value);
text.value = "";
}
}
function remove(index: number) {
const next = [...props.modelValue];
next.splice(index, 1);
emit("update:modelValue", next);
}
</script>
<template>
<div class="flex flex-wrap items-center gap-1 rounded-md border border-border bg-surface/60 px-2 py-1.5 focus-within:border-accent">
<span
v-for="(v, i) in modelValue"
:key="`${v}-${i}`"
class="inline-flex items-center gap-1 rounded bg-accent/15 px-2 py-0.5 text-xs text-accent"
>
{{ v }}
<button
type="button"
class="text-accent/70 hover:text-accent"
:aria-label="'remove'"
@click="remove(i)"
>
&times;
</button>
</span>
<input
v-model="text"
type="text"
class="min-w-[8rem] flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
:placeholder="placeholder"
@keydown="onKeydown"
@blur="onBlur"
/>
</div>
</template>