mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
c955db03c3
commit
c090281cb2
30 changed files with 1793 additions and 422 deletions
|
|
@ -655,6 +655,110 @@
|
|||
justify-self: end;
|
||||
}
|
||||
|
||||
.node-editor {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.node-nested {
|
||||
@apply ml-4 border-l border-border pl-3;
|
||||
}
|
||||
|
||||
.leaf-row {
|
||||
@apply mb-1.5 flex flex-wrap items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||
}
|
||||
|
||||
.node-select {
|
||||
@apply w-[120px] shrink-0 rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||
}
|
||||
|
||||
.node-path {
|
||||
@apply w-[160px] shrink-0;
|
||||
}
|
||||
|
||||
.node-values {
|
||||
@apply min-w-[180px] flex-1;
|
||||
}
|
||||
|
||||
.node-exclude {
|
||||
@apply flex shrink-0 cursor-pointer items-center gap-1.5 text-[12px] font-medium text-muted;
|
||||
}
|
||||
|
||||
.node-group {
|
||||
@apply mb-1.5 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||
}
|
||||
|
||||
.node-group-head {
|
||||
@apply mb-2 flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.node-combo {
|
||||
@apply w-[130px] rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text;
|
||||
}
|
||||
|
||||
.node-combo-label {
|
||||
@apply inline-flex items-center rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
@apply ml-auto flex flex-wrap items-center gap-1.5;
|
||||
}
|
||||
|
||||
.node-add {
|
||||
@apply px-2 py-1 text-[12px];
|
||||
}
|
||||
|
||||
.node-children {
|
||||
@apply flex flex-col gap-1.5;
|
||||
}
|
||||
|
||||
.node-empty {
|
||||
@apply py-1 text-[12px] italic text-muted;
|
||||
}
|
||||
|
||||
.test-payload {
|
||||
@apply font-mono text-[12px];
|
||||
}
|
||||
|
||||
.test-result {
|
||||
@apply mt-1 flex flex-col gap-1.5 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||
}
|
||||
|
||||
.test-result.ok {
|
||||
@apply border-ok;
|
||||
}
|
||||
|
||||
.test-badge {
|
||||
@apply inline-flex w-fit items-center rounded-full bg-bad px-2 py-0.5 text-[11px] font-semibold text-white;
|
||||
}
|
||||
|
||||
.test-result.ok .test-badge {
|
||||
@apply bg-ok;
|
||||
}
|
||||
|
||||
.test-explanation {
|
||||
@apply font-mono text-[12px] text-muted;
|
||||
}
|
||||
|
||||
.fragments-list {
|
||||
@apply mb-2 flex flex-col gap-1.5;
|
||||
}
|
||||
|
||||
.fragment-row {
|
||||
@apply flex items-center gap-2 rounded-sm border border-border bg-surface-2 p-2;
|
||||
}
|
||||
|
||||
.fragment-name {
|
||||
@apply min-w-0 flex-1 truncate text-[13px] font-medium text-text;
|
||||
}
|
||||
|
||||
.fragment-action {
|
||||
@apply shrink-0 px-2 py-1 text-[12px];
|
||||
}
|
||||
|
||||
.fragment-save {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* ---- Toasts ---- */
|
||||
.toasts {
|
||||
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@
|
|||
:open="editorOpen"
|
||||
:route="editing"
|
||||
:saving="saving"
|
||||
:group-id="selectedGroup?.id ?? null"
|
||||
@close="editorOpen = false"
|
||||
@save="onSave"
|
||||
/>
|
||||
|
|
|
|||
141
app/components/FilterNodeEditor.vue
Normal file
141
app/components/FilterNodeEditor.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
66
app/components/TagInput.vue
Normal file
66
app/components/TagInput.vue
Normal 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)"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
181
app/composables/useFilterNode.ts
Normal file
181
app/composables/useFilterNode.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import type { Filter, FilterNode, FilterOp, FilterType } from "~/types";
|
||||
|
||||
export interface LeafForm {
|
||||
type: FilterType;
|
||||
path: string;
|
||||
op: FilterOp;
|
||||
values: string[];
|
||||
exclude: boolean;
|
||||
}
|
||||
|
||||
export type NodeKind = "leaf" | "all" | "any" | "not";
|
||||
|
||||
export interface NodeForm {
|
||||
kind: NodeKind;
|
||||
leaf: LeafForm;
|
||||
children: NodeForm[];
|
||||
child: NodeForm | null;
|
||||
}
|
||||
|
||||
export function blankLeafForm(): LeafForm {
|
||||
return { type: "event", path: "", op: "eq", values: [], exclude: false };
|
||||
}
|
||||
|
||||
export function blankNode(kind: NodeKind): NodeForm {
|
||||
return {
|
||||
kind,
|
||||
leaf: blankLeafForm(),
|
||||
children: kind === "all" || kind === "any" ? [blankNode("leaf")] : [],
|
||||
child: kind === "not" ? blankNode("leaf") : null,
|
||||
};
|
||||
}
|
||||
|
||||
function matchToValues(match: string | string[] | undefined): string[] {
|
||||
if (match === undefined || match === null) return [];
|
||||
return Array.isArray(match) ? [...match] : [match];
|
||||
}
|
||||
|
||||
function leafToForm(f: Filter): LeafForm {
|
||||
return {
|
||||
type: f.type,
|
||||
path: f.path ?? "",
|
||||
op: f.op ?? "eq",
|
||||
values: matchToValues(f.match),
|
||||
exclude: !!f.exclude,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeToForm(node: FilterNode): NodeForm {
|
||||
if ("all" in node) {
|
||||
return { kind: "all", leaf: blankLeafForm(), children: node.all.map(nodeToForm), child: null };
|
||||
}
|
||||
if ("any" in node) {
|
||||
return { kind: "any", leaf: blankLeafForm(), children: node.any.map(nodeToForm), child: null };
|
||||
}
|
||||
if ("not" in node) {
|
||||
return { kind: "not", leaf: blankLeafForm(), children: [], child: nodeToForm(node.not) };
|
||||
}
|
||||
return { kind: "leaf", leaf: leafToForm(node), children: [], child: null };
|
||||
}
|
||||
|
||||
export function formToLeaf(lf: LeafForm): Filter | null {
|
||||
const values = lf.values.map((v) => v.trim()).filter((v) => v.length > 0);
|
||||
if (lf.op !== "exists" && values.length === 0) return null;
|
||||
if (lf.type === "field" && lf.path.trim().length === 0) return null;
|
||||
const filter: Filter = { type: lf.type };
|
||||
if (lf.type === "field") filter.path = lf.path.trim();
|
||||
if (lf.op !== "eq") filter.op = lf.op;
|
||||
if (lf.op !== "exists") filter.match = values.length === 1 ? values[0] : values;
|
||||
if (lf.exclude) filter.exclude = true;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function formToNode(nf: NodeForm): FilterNode | null {
|
||||
if (nf.kind === "leaf") return formToLeaf(nf.leaf);
|
||||
if (nf.kind === "all" || nf.kind === "any") {
|
||||
const children = nf.children.map(formToNode).filter((c): c is FilterNode => c !== null);
|
||||
if (children.length === 0) return null;
|
||||
return nf.kind === "all" ? { all: children } : { any: children };
|
||||
}
|
||||
if (nf.kind === "not") {
|
||||
const child = nf.child ? formToNode(nf.child) : null;
|
||||
if (!child) return null;
|
||||
return { not: child };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isTrivialNode(nf: NodeForm): boolean {
|
||||
if (nf.kind === "leaf") {
|
||||
return nf.leaf.type !== "field" && nf.leaf.op === "eq" && !nf.leaf.exclude;
|
||||
}
|
||||
if (nf.kind === "all") return nf.children.every(isTrivialNode);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function flattenLeaves(nf: NodeForm): Filter[] {
|
||||
const out: Filter[] = [];
|
||||
const walk = (n: NodeForm): void => {
|
||||
if (n.kind === "leaf") {
|
||||
const f = formToLeaf(n.leaf);
|
||||
if (f) out.push(f);
|
||||
return;
|
||||
}
|
||||
if (n.kind === "all" || n.kind === "any") n.children.forEach(walk);
|
||||
else if (n.kind === "not" && n.child) walk(n.child);
|
||||
};
|
||||
walk(nf);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function nodeFormToRouteFilters(nf: NodeForm): { filters: Filter[]; ast?: FilterNode } {
|
||||
if (isTrivialNode(nf)) {
|
||||
return { filters: flattenLeaves(nf) };
|
||||
}
|
||||
const ast = formToNode(nf);
|
||||
return { filters: [], ast: ast ?? undefined };
|
||||
}
|
||||
|
||||
export function collectEvents(node: FilterNode): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (n: FilterNode): void => {
|
||||
if ("all" in n) {
|
||||
n.all.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if ("any" in n) {
|
||||
n.any.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if ("not" in n) {
|
||||
walk(n.not);
|
||||
return;
|
||||
}
|
||||
if (n.type === "event") {
|
||||
const m = n.match;
|
||||
if (Array.isArray(m)) out.push(...m);
|
||||
else if (typeof m === "string" && m) out.push(m);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function describeLeaf(f: Filter, t: (key: string) => string): string {
|
||||
const label = f.type === "field" ? (f.path ?? f.type) : t("filter." + f.type);
|
||||
const op = f.op ?? "eq";
|
||||
const match = f.match;
|
||||
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
|
||||
const value =
|
||||
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
|
||||
let base: string;
|
||||
if (f.type === "keyword") {
|
||||
base = `${label} ${t("filterNode.matches")} ${value}`;
|
||||
} else if (op === "exists") {
|
||||
base = `${label} ${t("filterOp.exists")}`;
|
||||
} else if (op === "eq") {
|
||||
base = `${label} ${t("filterNode.is")} ${value}`;
|
||||
} else {
|
||||
base = `${label} ${t("filterOp." + op)} ${value}`;
|
||||
}
|
||||
return f.exclude ? `${t("filterNode.not")} (${base})` : base;
|
||||
}
|
||||
|
||||
export function describeNode(node: FilterNode, t: (key: string) => string): string {
|
||||
if ("all" in node) {
|
||||
return node.all
|
||||
.map((c) => describeNode(c, t))
|
||||
.filter((s) => s.length)
|
||||
.join(` ${t("filterNode.and")} `);
|
||||
}
|
||||
if ("any" in node) {
|
||||
const parts = node.any
|
||||
.map((c) => describeNode(c, t))
|
||||
.filter((s) => s.length);
|
||||
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
|
||||
}
|
||||
if ("not" in node) {
|
||||
return `${t("filterNode.not")} (${describeNode(node.not, t)})`;
|
||||
}
|
||||
return describeLeaf(node, t);
|
||||
}
|
||||
34
app/composables/useFragments.ts
Normal file
34
app/composables/useFragments.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { NamedFragment } from "~/types";
|
||||
|
||||
export function useFragmentsApi() {
|
||||
const { needLogin } = useAuthState();
|
||||
const fragments = ref<NamedFragment[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(groupId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const data = await apiFetch<{ fragments?: NamedFragment[] }>(
|
||||
`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`,
|
||||
);
|
||||
fragments.value = data.fragments ?? [];
|
||||
} catch (err) {
|
||||
if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(groupId: string, next: NamedFragment[]): Promise<void> {
|
||||
await apiFetch(`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ fragments: next }),
|
||||
});
|
||||
fragments.value = next;
|
||||
}
|
||||
|
||||
return { fragments, loading, needLogin, error, load, save };
|
||||
}
|
||||
|
|
@ -153,6 +153,28 @@ const en: Dict = {
|
|||
"filter.action": "Action",
|
||||
"filter.branch": "Branch",
|
||||
"filter.keyword": "Keyword",
|
||||
"filter.field": "Field",
|
||||
"filterOp.eq": "equals",
|
||||
"filterOp.ne": "not equals",
|
||||
"filterOp.contains": "contains",
|
||||
"filterOp.startsWith": "starts with",
|
||||
"filterOp.endsWith": "ends with",
|
||||
"filterOp.regex": "matches regex",
|
||||
"filterOp.gt": "greater than",
|
||||
"filterOp.gte": "greater or equal",
|
||||
"filterOp.lt": "less than",
|
||||
"filterOp.lte": "less or equal",
|
||||
"filterOp.in": "in",
|
||||
"filterOp.exists": "exists",
|
||||
"filterNode.all": "All of",
|
||||
"filterNode.any": "Any of",
|
||||
"filterNode.not": "not",
|
||||
"filterNode.is": "is",
|
||||
"filterNode.matches": "matches",
|
||||
"filterNode.and": "and",
|
||||
"filterNode.or": "or",
|
||||
"filterNode.unwrap": "unwrap",
|
||||
"filterNode.empty": "No conditions",
|
||||
"routeEditor.editTitle": "Edit route",
|
||||
"routeEditor.newTitle": "New route",
|
||||
"routeEditor.eyebrow": "Route",
|
||||
|
|
@ -219,6 +241,30 @@ const en: Dict = {
|
|||
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||
"routeEditor.errName": "Name is required",
|
||||
"routeEditor.errChannel": "Target {n} channel ID is required",
|
||||
"routeEditor.addLeaf": "Add condition",
|
||||
"routeEditor.addGroup": "Add group",
|
||||
"routeEditor.addNot": "Add not",
|
||||
"routeEditor.remove": "Remove",
|
||||
"routeEditor.op": "Operator",
|
||||
"routeEditor.pathPlaceholder": "payload.path",
|
||||
"routeEditor.valuesPlaceholder": "Type a value and press Enter",
|
||||
"routeEditor.testMatch": "Test match",
|
||||
"routeEditor.testEvent": "Event (optional)",
|
||||
"routeEditor.testPayloadPlaceholder": "Paste a JSON webhook payload",
|
||||
"routeEditor.testRun": "Run test",
|
||||
"routeEditor.testMatched": "Matches",
|
||||
"routeEditor.testNotMatched": "Does not match",
|
||||
"routeEditor.testInvalidJson": "Invalid JSON",
|
||||
"routeEditor.fragments": "Fragments",
|
||||
"routeEditor.fragmentsEmpty": "No fragments yet",
|
||||
"routeEditor.fragmentsInsert": "Insert",
|
||||
"routeEditor.fragmentsDelete": "Delete",
|
||||
"routeEditor.fragmentsSave": "Save as fragment",
|
||||
"routeEditor.fragmentsNamePlaceholder": "Fragment name",
|
||||
"routeEditor.fragmentsNameRequired": "Fragment name is required",
|
||||
"routeEditor.errPath": "Field filters need a path",
|
||||
"routeEditor.errValues": "Add at least one value",
|
||||
"routeEditor.errGroupEmpty": "Group needs at least one condition",
|
||||
"groupEditor.editTitle": "Edit group",
|
||||
"groupEditor.newTitle": "New group",
|
||||
"groupEditor.eyebrow": "Group",
|
||||
|
|
@ -479,6 +525,28 @@ const zh: Dict = {
|
|||
"filter.action": "动作",
|
||||
"filter.branch": "分支",
|
||||
"filter.keyword": "关键词",
|
||||
"filter.field": "字段",
|
||||
"filterOp.eq": "等于",
|
||||
"filterOp.ne": "不等于",
|
||||
"filterOp.contains": "包含",
|
||||
"filterOp.startsWith": "开头是",
|
||||
"filterOp.endsWith": "结尾是",
|
||||
"filterOp.regex": "正则匹配",
|
||||
"filterOp.gt": "大于",
|
||||
"filterOp.gte": "大于等于",
|
||||
"filterOp.lt": "小于",
|
||||
"filterOp.lte": "小于等于",
|
||||
"filterOp.in": "属于",
|
||||
"filterOp.exists": "存在",
|
||||
"filterNode.all": "全部满足",
|
||||
"filterNode.any": "任一满足",
|
||||
"filterNode.not": "非",
|
||||
"filterNode.is": "是",
|
||||
"filterNode.matches": "匹配",
|
||||
"filterNode.and": "且",
|
||||
"filterNode.or": "或",
|
||||
"filterNode.unwrap": "取消非",
|
||||
"filterNode.empty": "暂无条件",
|
||||
"routeEditor.editTitle": "编辑路由",
|
||||
"routeEditor.newTitle": "新建路由",
|
||||
"routeEditor.eyebrow": "路由",
|
||||
|
|
@ -544,6 +612,30 @@ const zh: Dict = {
|
|||
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||
"routeEditor.errName": "名称为必填项",
|
||||
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
||||
"routeEditor.addLeaf": "添加条件",
|
||||
"routeEditor.addGroup": "添加分组",
|
||||
"routeEditor.addNot": "添加非",
|
||||
"routeEditor.remove": "删除",
|
||||
"routeEditor.op": "操作符",
|
||||
"routeEditor.pathPlaceholder": "payload.path",
|
||||
"routeEditor.valuesPlaceholder": "输入值后回车",
|
||||
"routeEditor.testMatch": "测试匹配",
|
||||
"routeEditor.testEvent": "事件类型(可选)",
|
||||
"routeEditor.testPayloadPlaceholder": "粘贴 JSON webhook 事件样本",
|
||||
"routeEditor.testRun": "运行测试",
|
||||
"routeEditor.testMatched": "匹配",
|
||||
"routeEditor.testNotMatched": "不匹配",
|
||||
"routeEditor.testInvalidJson": "无效的 JSON",
|
||||
"routeEditor.fragments": "过滤器片段",
|
||||
"routeEditor.fragmentsEmpty": "暂无片段",
|
||||
"routeEditor.fragmentsInsert": "插入",
|
||||
"routeEditor.fragmentsDelete": "删除",
|
||||
"routeEditor.fragmentsSave": "存为片段",
|
||||
"routeEditor.fragmentsNamePlaceholder": "片段名称",
|
||||
"routeEditor.fragmentsNameRequired": "请填写片段名称",
|
||||
"routeEditor.errPath": "字段过滤需要路径",
|
||||
"routeEditor.errValues": "至少需要一个值",
|
||||
"routeEditor.errGroupEmpty": "分组至少需要一个条件",
|
||||
"groupEditor.editTitle": "编辑分组",
|
||||
"groupEditor.newTitle": "新建分组",
|
||||
"groupEditor.eyebrow": "分组",
|
||||
|
|
|
|||
70
app/types.ts
70
app/types.ts
|
|
@ -1,7 +1,53 @@
|
|||
export type FilterType =
|
||||
| "event"
|
||||
| "repo"
|
||||
| "actor"
|
||||
| "action"
|
||||
| "branch"
|
||||
| "keyword"
|
||||
| "field";
|
||||
|
||||
export type FilterOp =
|
||||
| "eq"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "startsWith"
|
||||
| "endsWith"
|
||||
| "regex"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "in"
|
||||
| "exists";
|
||||
|
||||
export interface Filter {
|
||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
||||
match: string | string[];
|
||||
type: FilterType;
|
||||
match?: string | string[];
|
||||
exclude?: boolean;
|
||||
path?: string;
|
||||
op?: FilterOp;
|
||||
}
|
||||
|
||||
export interface FilterAll {
|
||||
all: FilterNode[];
|
||||
}
|
||||
|
||||
export interface FilterAny {
|
||||
any: FilterNode[];
|
||||
}
|
||||
|
||||
export interface FilterNot {
|
||||
not: FilterNode;
|
||||
}
|
||||
|
||||
export type FilterNode = Filter | FilterAll | FilterAny | FilterNot;
|
||||
|
||||
export interface NamedFragment {
|
||||
id: string;
|
||||
groupId?: string;
|
||||
name: string;
|
||||
node: FilterNode;
|
||||
}
|
||||
|
||||
export interface RouteTarget {
|
||||
|
|
@ -22,6 +68,7 @@ export interface Route {
|
|||
fallback?: boolean;
|
||||
stop?: boolean;
|
||||
discordRoleIds?: string[];
|
||||
ast?: FilterNode;
|
||||
}
|
||||
|
||||
export type GroupRole = "owner" | "admin" | "viewer";
|
||||
|
|
@ -161,9 +208,24 @@ export const ROUTE_TEMPLATES: RouteTemplate[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword", "field"] as const;
|
||||
|
||||
export function fmtMatch(match: string | string[]): string {
|
||||
export const FILTER_OPS: FilterOp[] = [
|
||||
"eq",
|
||||
"ne",
|
||||
"contains",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"regex",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"in",
|
||||
"exists",
|
||||
];
|
||||
|
||||
export function fmtMatch(match: string | string[] | undefined): string {
|
||||
if (Array.isArray(match)) return match.join(", ");
|
||||
return String(match ?? "");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue