mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
13
app/components/AppToasts.vue
Normal file
13
app/components/AppToasts.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div class="toasts">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismiss(t.id)">
|
||||
{{ t.msg }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { toasts, dismiss } = useToasts();
|
||||
</script>
|
||||
85
app/components/AuditLog.vue
Normal file
85
app/components/AuditLog.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="log-toolbar">
|
||||
<span class="kpi-label">{{ t("audit.title") }}</span>
|
||||
<div class="log-filters">
|
||||
<label class="filter-label">{{ t("audit.filterGroup") }}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="selectedGroupId"
|
||||
:disabled="loading"
|
||||
@change="onGroupFilter"
|
||||
>
|
||||
<option value="">{{ t("audit.allGroups") }}</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||
{{ g.name || g.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">
|
||||
{{ t("audit.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-else-if="!loading && !entries.length" class="empty-log">{{ t("audit.empty") }}</p>
|
||||
|
||||
<div class="log-list">
|
||||
<article v-for="e in entries" :key="e.id ?? e.ts" class="log-entry">
|
||||
<div class="log-head">
|
||||
<span class="dot ok"></span>
|
||||
<span class="log-route">{{ e.action }}</span>
|
||||
<span class="log-event">{{ e.actorLogin || e.actorId || "-" }}</span>
|
||||
<span class="log-status ok">{{ e.targetType || "-" }}</span>
|
||||
<span class="log-time">{{ fmtTime(e.ts) }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span
|
||||
><b>{{ t("audit.target") }}</b
|
||||
><code>{{ e.targetId || "-" }}</code></span
|
||||
>
|
||||
<span
|
||||
><b>{{ t("audit.group") }}</b
|
||||
><code>{{ e.groupId || "-" }}</code></span
|
||||
>
|
||||
<span v-if="e.detail && Object.keys(e.detail).length > 0"
|
||||
><b>{{ t("audit.detail") }}</b
|
||||
><code>{{ JSON.stringify(e.detail) }}</code></span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditEntry, Group } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
entries: AuditEntry[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
groups: Group[];
|
||||
selectedGroupId: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
(e: "filter"): void;
|
||||
(e: "update:selectedGroupId", value: string): void;
|
||||
}>();
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
emit("refresh");
|
||||
}
|
||||
|
||||
function onGroupFilter(e: Event): void {
|
||||
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
|
||||
emit("filter");
|
||||
}
|
||||
</script>
|
||||
487
app/components/ConsolePage.vue
Normal file
487
app/components/ConsolePage.vue
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
<template>
|
||||
<div class="shell">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">WH</div>
|
||||
<div>
|
||||
<h1>WebHooker</h1>
|
||||
<span class="tagline">{{ t("app.tagline") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
|
||||
<button
|
||||
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
|
||||
class="btn btn-accent"
|
||||
@click="openNew"
|
||||
>
|
||||
{{ t("app.newRoute") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
|
||||
class="btn btn-accent"
|
||||
@click="openNewGroup"
|
||||
>
|
||||
{{ t("app.newGroup") }}
|
||||
</button>
|
||||
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">{{ t("app.signOut") }}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div v-if="needLogin" class="login">
|
||||
<div class="login-card">
|
||||
<h2>{{ t("login.title") }}</h2>
|
||||
<p v-if="forbidden">{{ t("login.forbidden") }}</p>
|
||||
<p v-else>{{ t("login.prompt") }}</p>
|
||||
<a class="btn btn-accent btn-lg" href="/admin/login">{{ t("login.button") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Group detail view -->
|
||||
<template v-if="selectedGroup">
|
||||
<section class="toolbar">
|
||||
<div class="crumbs">
|
||||
<button class="btn btn-ghost btn-sm" @click="exitGroup">{{ t("group.back") }}</button>
|
||||
<span class="kpi-label">{{ t("group.routesIn", { name: selectedGroup.name }) }}</span>
|
||||
<span class="kpi">{{ groupRoutes.length }}</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
|
||||
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
|
||||
|
||||
<section class="routes">
|
||||
<RouteCard
|
||||
v-for="(r, i) in groupRoutes"
|
||||
:key="r.id"
|
||||
:route="r"
|
||||
:at-first="i === 0"
|
||||
:at-last="i === groupRoutes.length - 1"
|
||||
:readonly="!canEditRoutes(selectedGroup.id)"
|
||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||
@toggle="onToggle"
|
||||
@edit="openEdit"
|
||||
@delete="onDelete"
|
||||
@move="onMove"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
|
||||
<p>{{ t("routes.emptyGroup") }}</p>
|
||||
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
|
||||
{{ t("routes.createFirst") }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<MembersPanel
|
||||
:group="selectedGroup"
|
||||
:can-edit="canEditGroup(selectedGroup.id)"
|
||||
:saving="savingGroup"
|
||||
@save="onSaveGroupFromPanel"
|
||||
/>
|
||||
|
||||
<WebhookPanel
|
||||
v-if="canEditGroup(selectedGroup.id)"
|
||||
:group-id="selectedGroup.id"
|
||||
:can-edit="canEditGroup(selectedGroup.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Top-level views -->
|
||||
<template v-else>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ active: view === 'groups' }"
|
||||
@click="switchView('groups')"
|
||||
>
|
||||
{{ t("tab.groups") }}
|
||||
</button>
|
||||
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
|
||||
{{ t("tab.logs") }}
|
||||
</button>
|
||||
<button class="tab" :class="{ active: view === 'audit' }" @click="switchView('audit')">
|
||||
{{ t("tab.audit") }}
|
||||
</button>
|
||||
</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 cursor-pointer"
|
||||
: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>
|
||||
<span v-if="roleOf(g.id)" class="badge lang">{{
|
||||
t("role.badge", { role: t("roles." + roleOf(g.id)) })
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="canEditGroup(g.id)" 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.members") }}</b
|
||||
><code>{{
|
||||
(g.members ?? []).length || (g.adminIds || []).length || "—"
|
||||
}}</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 mt-2.5 text-right text-xs font-medium text-accent">
|
||||
{{ 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-if="view === 'logs'">
|
||||
<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"
|
||||
:error="logsError"
|
||||
:groups="groups"
|
||||
:selected-group-id="logFilterGroup"
|
||||
@refresh="loadLogs(50, logFilterGroup || undefined)"
|
||||
@filter="loadLogs(50, logFilterGroup || undefined)"
|
||||
@update:selected-group-id="logFilterGroup = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view === 'audit'">
|
||||
<AuditLog
|
||||
:entries="auditEntries"
|
||||
:loading="auditLoading"
|
||||
:error="auditError"
|
||||
:groups="groups"
|
||||
:selected-group-id="auditFilterGroup"
|
||||
@refresh="loadAudit(50, auditFilterGroup || undefined)"
|
||||
@filter="loadAudit(50, auditFilterGroup || undefined)"
|
||||
@update:selected-group-id="auditFilterGroup = $event"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RouteEditor
|
||||
:open="editorOpen"
|
||||
:route="editing"
|
||||
:saving="saving"
|
||||
@close="editorOpen = false"
|
||||
@save="onSave"
|
||||
/>
|
||||
|
||||
<GroupEditor
|
||||
:open="groupEditorOpen"
|
||||
:group="editingGroup"
|
||||
:saving="savingGroup"
|
||||
:super-admin="isSuper"
|
||||
@close="groupEditorOpen = false"
|
||||
@save="onSaveGroup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Group, Route } from "~/types";
|
||||
import { useAuditApi } from "~/composables/useAudit";
|
||||
import WebhookPanel from "~/components/WebhookPanel.vue";
|
||||
|
||||
const { t, toggle } = useI18n();
|
||||
const { push } = useToasts();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* The console view mirrors the URL path so tabs are deep-linkable:
|
||||
* /admin (groups) · /admin/logs · /admin/audit. Unknown slugs 404.
|
||||
*/
|
||||
const view = computed<"groups" | "logs" | "audit" | null>(() => {
|
||||
const seg = route.path.split("/").filter(Boolean)[1];
|
||||
if (!seg) return "groups";
|
||||
if (seg === "groups" || seg === "logs" || seg === "audit") return seg;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (view.value === null) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
|
||||
}
|
||||
|
||||
const {
|
||||
logs,
|
||||
loading: logsLoading,
|
||||
error: logsError,
|
||||
load: loadLogs,
|
||||
} = useSendLogs();
|
||||
const {
|
||||
entries: auditEntries,
|
||||
loading: auditLoading,
|
||||
error: auditError,
|
||||
load: loadAudit,
|
||||
} = useAuditApi();
|
||||
const {
|
||||
groups,
|
||||
isSuper,
|
||||
roles,
|
||||
roleOf,
|
||||
canEditGroup,
|
||||
canEditRoutes,
|
||||
loading: groupsLoading,
|
||||
needLogin,
|
||||
error: groupsError,
|
||||
load: loadGroups,
|
||||
save: saveGroups,
|
||||
rename: groupsRename,
|
||||
} = 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 selectedGroup = ref<Group | null>(null);
|
||||
|
||||
const groupEditorOpen = ref(false);
|
||||
const editingGroup = ref<Group | null>(null);
|
||||
const savingGroup = ref(false);
|
||||
|
||||
const logFilterGroup = ref("");
|
||||
const auditFilterGroup = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
forbidden.value = params.get("error") === "forbidden";
|
||||
const invite = params.get("invite");
|
||||
if (invite === "ok") push(t("members.inviteOk"));
|
||||
else if (invite != null && invite !== "ok") push(t("members.inviteBad"), "bad");
|
||||
if (params.get("install") === "ok") push(t("install.ok"));
|
||||
}
|
||||
loadGroups();
|
||||
loadLogs(50, logFilterGroup.value || undefined);
|
||||
if (view.value === "audit") loadAudit(50, auditFilterGroup.value || undefined);
|
||||
});
|
||||
|
||||
function switchView(next: "groups" | "logs" | "audit"): void {
|
||||
const path = next === "groups" ? "/admin" : `/admin/${next}`;
|
||||
if (route.path !== path) router.replace(path);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function openEdit(route: Route): void {
|
||||
editing.value = route;
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
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 = groupRoutes.value.map((r) => (r.id === editing.value!.id ? route : r));
|
||||
} else {
|
||||
if (groupRoutes.value.some((r) => r.id === route.id)) {
|
||||
push(t("toast.routeIdExists"), "bad");
|
||||
return;
|
||||
}
|
||||
next = [...groupRoutes.value, route];
|
||||
}
|
||||
await saveGroupRoutes(group.id, next);
|
||||
editorOpen.value = false;
|
||||
push(t("toast.routesSaved"));
|
||||
} catch (err) {
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onMove(route: Route, dir: -1 | 1): Promise<void> {
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
const i = groupRoutes.value.findIndex((r) => r.id === route.id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= groupRoutes.value.length) return;
|
||||
const next = [...groupRoutes.value];
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
try {
|
||||
await saveGroupRoutes(group.id, next);
|
||||
} catch (err) {
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
loadGroupRoutes(group.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(route: Route): Promise<void> {
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
try {
|
||||
await saveGroupRoutes(
|
||||
group.id,
|
||||
groupRoutes.value.map((r) => (r.id === route.id ? route : r)),
|
||||
);
|
||||
} catch (err) {
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
loadGroupRoutes(group.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(route: Route): Promise<void> {
|
||||
const group = selectedGroup.value;
|
||||
if (!group) return;
|
||||
if (!window.confirm(t("confirm.deleteRoute", { name: route.name || route.id }))) return;
|
||||
try {
|
||||
await saveGroupRoutes(
|
||||
group.id,
|
||||
groupRoutes.value.filter((r) => r.id !== route.id),
|
||||
);
|
||||
push(t("toast.routeDeleted"));
|
||||
} catch (err) {
|
||||
push(t("toast.deleteFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
}
|
||||
}
|
||||
|
||||
function openNewGroup(): void {
|
||||
editingGroup.value = null;
|
||||
groupEditorOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditGroup(group: Group): void {
|
||||
editingGroup.value = group;
|
||||
groupEditorOpen.value = true;
|
||||
}
|
||||
|
||||
async function onSaveGroupFromPanel(group: Group): Promise<void> {
|
||||
savingGroup.value = true;
|
||||
try {
|
||||
const next = groups.value.map((g) => (g.id === group.id ? group : g));
|
||||
await saveGroups(next);
|
||||
selectedGroup.value = group;
|
||||
push(t("toast.groupSaved"));
|
||||
} catch (err) {
|
||||
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
if (selectedGroup.value) {
|
||||
loadGroups();
|
||||
}
|
||||
} finally {
|
||||
savingGroup.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveGroup(group: Group): Promise<void> {
|
||||
savingGroup.value = true;
|
||||
try {
|
||||
const editing = editingGroup.value;
|
||||
let next: Group[];
|
||||
if (editing) {
|
||||
if (group.id !== editing.id) {
|
||||
// Id changed: rename first so routes/webhook secret/invites follow,
|
||||
// then persist the remaining edits under the new id.
|
||||
await groupsRename(editing.id, group.id);
|
||||
next = [...groups.value.filter((g) => g.id !== editing.id), group];
|
||||
} else {
|
||||
next = groups.value.map((g) => (g.id === editing.id ? group : g));
|
||||
}
|
||||
} else {
|
||||
if (groups.value.some((g) => g.id === group.id)) {
|
||||
push(t("toast.groupIdExists"), "bad");
|
||||
return;
|
||||
}
|
||||
next = [...groups.value, group];
|
||||
}
|
||||
await saveGroups(next);
|
||||
groupEditorOpen.value = false;
|
||||
push(t("toast.groupSaved"));
|
||||
await loadGroups();
|
||||
} catch (err) {
|
||||
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 = 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(t("toast.groupDeleted"));
|
||||
} catch (err) {
|
||||
push(t("toast.deleteFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
325
app/components/GroupEditor.vue
Normal file
325
app/components/GroupEditor.vue
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<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">
|
||||
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
|
||||
<button class="icon-btn" :title="t('groupEditor.close')" @click="close">✕</button>
|
||||
</div>
|
||||
<form class="editor-body" @submit.prevent="save">
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.name") }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.namePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.id") }}</label>
|
||||
<input
|
||||
v-model="form.id"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.idPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<div class="hint">
|
||||
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label>{{ t("groupEditor.language") }}</label>
|
||||
<input
|
||||
v-model="form.lang"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.langPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.emoji") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
|
||||
>
|
||||
<label class="inline">
|
||||
<input v-model="form.emoji" type="checkbox" />
|
||||
<span>{{ t("groupEditor.emojiLabel") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.membersNote") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
|
||||
>
|
||||
<p class="hint">
|
||||
{{ t("groupEditor.membersGoPanel") }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="superAdmin" class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.owners"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('groupEditor.ownersPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||
</div>
|
||||
<div v-else class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.owners") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
||||
>
|
||||
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.providers") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
|
||||
>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.providers.includes('github')"
|
||||
@change="toggleProvider('github', $event)"
|
||||
/>
|
||||
<span>GitHub</span>
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.providers.includes('gitea')"
|
||||
@change="toggleProvider('gitea', $event)"
|
||||
/>
|
||||
<span>Gitea</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.installationId") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.installationId"
|
||||
type="text"
|
||||
class="input"
|
||||
inputmode="numeric"
|
||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.logTarget") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
||||
>
|
||||
<select v-model="form.logPlatform" class="select">
|
||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<template v-if="form.logPlatform === 'discord'">
|
||||
<input
|
||||
v-model="form.logChannelId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logThreadId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="form.logPlatform === 'telegram'">
|
||||
<input
|
||||
v-model="form.logChatId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||
/>
|
||||
<input
|
||||
v-model="form.logTopicId"
|
||||
type="text"
|
||||
class="input mt-2"
|
||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
|
||||
</div>
|
||||
<div class="err">{{ formError }}</div>
|
||||
</form>
|
||||
<div class="editor-foot">
|
||||
<button class="btn btn-ghost" type="button" @click="close">
|
||||
{{ t("groupEditor.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">
|
||||
{{ t("groupEditor.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from "vue";
|
||||
import type { Group } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
group: Group | null;
|
||||
saving: boolean;
|
||||
superAdmin?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "save", group: Group): void;
|
||||
}>();
|
||||
|
||||
const isEdit = computed(() => props.group != null);
|
||||
const formError = ref("");
|
||||
const ownersReadonly = computed(() => (props.group?.owners ?? []).join(", "));
|
||||
|
||||
const form = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
owners: "",
|
||||
providers: [] as ("github" | "gitea")[],
|
||||
installationId: "",
|
||||
emoji: true,
|
||||
lang: "",
|
||||
logPlatform: "" as "" | "discord" | "telegram",
|
||||
logChannelId: "",
|
||||
logThreadId: "",
|
||||
logChatId: "",
|
||||
logTopicId: "",
|
||||
});
|
||||
|
||||
function splitList(text: string): string[] {
|
||||
return text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toggleProvider(p: "github" | "gitea", e: Event): void {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
form.providers = checked
|
||||
? [...new Set([...form.providers, p])]
|
||||
: form.providers.filter((x) => x !== p);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
const g = props.group;
|
||||
const lt = g?.logTarget;
|
||||
form.id = g?.id ?? "";
|
||||
form.name = g?.name ?? "";
|
||||
form.owners = (g?.owners ?? []).join(", ");
|
||||
form.providers = (g?.providers ?? []).filter(
|
||||
(p): p is "github" | "gitea" => p === "github" || p === "gitea",
|
||||
);
|
||||
form.installationId = g?.installationId != null ? String(g.installationId) : "";
|
||||
form.emoji = g?.emoji ?? true;
|
||||
form.lang = g?.lang ?? "";
|
||||
form.logPlatform = lt?.platform ?? "";
|
||||
form.logChannelId = lt?.channelId ?? "";
|
||||
form.logThreadId = lt?.threadId ?? "";
|
||||
form.logChatId = lt?.chatId ?? "";
|
||||
form.logTopicId = lt?.topicId ?? "";
|
||||
formError.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
function close(): void {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
formError.value = "";
|
||||
const id = form.id.trim();
|
||||
const name = form.name.trim();
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
formError.value = t("groupEditor.errIdFormat");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
formError.value = t("groupEditor.errName");
|
||||
return;
|
||||
}
|
||||
let logTarget:
|
||||
| { platform: "discord"; channelId: string; threadId?: string }
|
||||
| {
|
||||
platform: "telegram";
|
||||
chatId: string;
|
||||
topicId?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (form.logPlatform === "discord") {
|
||||
const channelId = form.logChannelId.trim();
|
||||
if (!channelId) {
|
||||
formError.value = t("groupEditor.errLogChannel");
|
||||
return;
|
||||
}
|
||||
logTarget = { platform: "discord", channelId, threadId: form.logThreadId.trim() || undefined };
|
||||
} else if (form.logPlatform === "telegram") {
|
||||
const chatId = form.logChatId.trim();
|
||||
if (!chatId) {
|
||||
formError.value = t("groupEditor.errLogChat");
|
||||
return;
|
||||
}
|
||||
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
|
||||
}
|
||||
const installationText = form.installationId.trim();
|
||||
let installationId: number | undefined;
|
||||
if (installationText) {
|
||||
installationId = Number(installationText);
|
||||
if (!Number.isInteger(installationId) || installationId <= 0) {
|
||||
formError.value = t("groupEditor.errInstallationId");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const owners = splitList(form.owners);
|
||||
const members = props.group?.members
|
||||
? props.group.members.map((m) => ({ ...m }))
|
||||
: props.group?.adminIds?.length
|
||||
? props.group.adminIds.map((login) => ({ login, role: "owner" as const }))
|
||||
: [];
|
||||
emit("save", {
|
||||
id,
|
||||
name,
|
||||
members,
|
||||
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
|
||||
owners: props.superAdmin ? (owners.length ? owners : undefined) : props.group?.owners,
|
||||
providers: form.providers.length ? form.providers : undefined,
|
||||
installationId,
|
||||
emoji: form.emoji,
|
||||
lang: form.lang.trim() || undefined,
|
||||
logTarget,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
65
app/components/LegalLayout.vue
Normal file
65
app/components/LegalLayout.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="mx-auto max-w-[760px] px-5 pb-20 pt-12">
|
||||
<header class="mb-7 flex flex-wrap items-center justify-between gap-4">
|
||||
<NuxtLink
|
||||
class="text-base font-extrabold tracking-[-0.01em] text-text no-underline"
|
||||
:to="q('/terms')"
|
||||
>Web<span class="text-accent">Hooker</span></NuxtLink
|
||||
>
|
||||
<nav class="flex gap-2">
|
||||
<NuxtLink
|
||||
class="rounded-full border border-transparent px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:class="active === 'terms' ? 'border-accent-border bg-accent-dim text-accent' : ''"
|
||||
:to="q('/terms')"
|
||||
>
|
||||
{{ t("服务条款", "Terms") }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="rounded-full border border-transparent px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:class="active === 'privacy' ? 'border-accent-border bg-accent-dim text-accent' : ''"
|
||||
:to="q('/privacy')"
|
||||
>
|
||||
{{ t("隐私政策", "Privacy") }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</header>
|
||||
<article
|
||||
class="rounded-xl border border-border bg-surface px-10 py-9 shadow-[0_1px_3px_var(--shadow)]"
|
||||
>
|
||||
<h1 class="mb-1.5 text-[26px] font-extrabold tracking-[-0.02em]">{{ title }}</h1>
|
||||
<p class="mb-6 text-[13px] text-muted">{{ t("最后更新", "Last updated") }}: {{ updated }}</p>
|
||||
<div class="legal-body" v-html="body" />
|
||||
</article>
|
||||
<footer class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-[13px] text-muted">WebHooker · GitHub → Discord</span>
|
||||
<NuxtLink
|
||||
class="rounded-full border border-border bg-surface px-3.5 py-1.5 text-[13px] text-muted no-underline transition-colors hover:text-text"
|
||||
:to="altLink"
|
||||
>{{ lang === "zh" ? "English" : "中文" }}</NuxtLink
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, type Lang } from "~/utils/legal";
|
||||
|
||||
const props = defineProps<{ active: "terms" | "privacy"; title: string; body: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const lang = computed<Lang>(() => pickLang(String(route.query.lang ?? "")));
|
||||
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
|
||||
const updated = computed(() => "2026-08-01");
|
||||
|
||||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
const q = (p: string): string => `${p}?lang=${lang.value}`;
|
||||
const altLink = computed(() =>
|
||||
q(props.active === "terms" ? "/terms" : "/privacy").replace(`lang=${lang.value}`, `lang=${altLang.value}`),
|
||||
);
|
||||
|
||||
useHead({ title: `${props.title} · WebHooker` });
|
||||
void config;
|
||||
</script>
|
||||
229
app/components/MembersPanel.vue
Normal file
229
app/components/MembersPanel.vue
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<template>
|
||||
<section class="members-panel">
|
||||
<div class="panel-head">
|
||||
<h3>{{ t("members.title") }}</h3>
|
||||
<span class="lbl-note">{{ t("members.note") }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!members.length" class="empty-log">{{ t("members.empty") }}</p>
|
||||
|
||||
<ul class="member-list">
|
||||
<li v-for="m in members" :key="m.login.toLowerCase()" class="member-row">
|
||||
<span class="member-login">{{ m.login }}</span>
|
||||
<select
|
||||
v-if="canEdit"
|
||||
class="filter-select"
|
||||
:value="m.role"
|
||||
:disabled="saving"
|
||||
@change="onRoleChange(m, $event)"
|
||||
>
|
||||
<option value="owner">{{ t("roles.owner") }}</option>
|
||||
<option value="admin">{{ t("roles.admin") }}</option>
|
||||
<option value="viewer">{{ t("roles.viewer") }}</option>
|
||||
</select>
|
||||
<span v-else class="role-pill" :class="m.role">{{ t("roles." + m.role) }}</span>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="icon-btn danger"
|
||||
:disabled="saving"
|
||||
:title="t('members.remove')"
|
||||
@click="removeMember(m)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="canEdit" class="member-add">
|
||||
<input
|
||||
v-model="newLogin"
|
||||
type="text"
|
||||
:placeholder="t('members.addPlaceholder')"
|
||||
class="text-input"
|
||||
@keydown.enter.prevent="addMember"
|
||||
/>
|
||||
<select v-model="newRole" class="filter-select">
|
||||
<option value="owner">{{ t("roles.owner") }}</option>
|
||||
<option value="admin">{{ t("roles.admin") }}</option>
|
||||
<option value="viewer">{{ t("roles.viewer") }}</option>
|
||||
</select>
|
||||
<button class="btn btn-accent btn-sm" :disabled="saving" @click="addMember">
|
||||
{{ t("members.addBtn") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canEdit" class="invite-box">
|
||||
<h4>
|
||||
{{ t("members.invites") }}
|
||||
<span class="lbl-note">{{ t("members.inviteNote") }}</span>
|
||||
</h4>
|
||||
<div class="invite-create">
|
||||
<select v-model="inviteRole" class="filter-select">
|
||||
<option value="admin">{{ t("roles.admin") }}</option>
|
||||
<option value="viewer">{{ t("roles.viewer") }}</option>
|
||||
</select>
|
||||
<button class="btn btn-accent btn-sm" :disabled="saving || inviting" @click="createInvite">
|
||||
{{ t("members.createInvite") }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="invites.length" class="invite-list">
|
||||
<li v-for="inv in invites" :key="inv.token" class="invite-row">
|
||||
<code class="invite-token">{{ shortToken(inv.token) }}</code>
|
||||
<span class="role-pill" :class="inv.role">{{ t("roles." + inv.role) }}</span>
|
||||
<span class="invite-exp">{{ fmtExp(inv.expiresAt) }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="copyInvite(inv)">
|
||||
{{ copiedToken === inv.token ? t("members.copied") : t("members.copyLink") }}
|
||||
</button>
|
||||
<button class="icon-btn danger" @click="revoke(inv)">{{ t("members.revoke") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty-log">{{ t("members.noInvites") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="err">{{ formError }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import type { Group, GroupInvite, GroupMember, GroupRole } from "~/types";
|
||||
import { useInvitesApi } from "~/composables/useInvites";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ group: Group; canEdit: boolean; saving: boolean }>();
|
||||
const emit = defineEmits<{ (e: "save", group: Group): void }>();
|
||||
|
||||
const invitesApi = useInvitesApi();
|
||||
const members = ref<GroupMember[]>([]);
|
||||
const invites = ref<GroupInvite[]>([]);
|
||||
const newLogin = ref("");
|
||||
const newRole = ref<GroupRole>("admin");
|
||||
const inviteRole = ref<"admin" | "viewer">("admin");
|
||||
const inviting = ref(false);
|
||||
const copiedToken = ref("");
|
||||
const formError = ref("");
|
||||
|
||||
watch(
|
||||
() => props.group,
|
||||
(g) => {
|
||||
members.value = (g.members ?? []).map((m) => ({ ...m }));
|
||||
formError.value = "";
|
||||
if (props.canEdit) loadInvites();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function loadInvites(): Promise<void> {
|
||||
invites.value = await invitesApi.list(props.group.id);
|
||||
}
|
||||
|
||||
function ownerCount(): number {
|
||||
return members.value.filter((m) => m.role === "owner").length;
|
||||
}
|
||||
|
||||
function onRoleChange(m: GroupMember, e: Event): void {
|
||||
const role = (e.target as HTMLSelectElement).value as GroupRole;
|
||||
if (role === "viewer" && m.role === "owner" && ownerCount() === 1) {
|
||||
formError.value = t("members.errLastOwner");
|
||||
return;
|
||||
}
|
||||
m.role = role;
|
||||
emitSave();
|
||||
}
|
||||
|
||||
function addMember(): void {
|
||||
const login = newLogin.value.trim();
|
||||
if (!login) return;
|
||||
if (members.value.some((m) => m.login.toLowerCase() === login.toLowerCase())) {
|
||||
formError.value = t("members.errOwner");
|
||||
return;
|
||||
}
|
||||
members.value.push({ login, role: newRole.value });
|
||||
newLogin.value = "";
|
||||
emitSave();
|
||||
}
|
||||
|
||||
function removeMember(m: GroupMember): void {
|
||||
if (m.role === "owner" && ownerCount() === 1) {
|
||||
formError.value = t("members.errLastOwner");
|
||||
return;
|
||||
}
|
||||
members.value = members.value.filter((x) => x !== m);
|
||||
emitSave();
|
||||
}
|
||||
|
||||
function emitSave(): void {
|
||||
emit("save", { ...props.group, members: members.value.map((m) => ({ ...m })) });
|
||||
}
|
||||
|
||||
async function createInvite(): Promise<void> {
|
||||
inviting.value = true;
|
||||
formError.value = "";
|
||||
try {
|
||||
const url = await invitesApi.create(props.group.id, inviteRole.value);
|
||||
const token = url.split("token=")[1] ?? "";
|
||||
if (token) {
|
||||
invites.value = [
|
||||
{
|
||||
token,
|
||||
groupId: props.group.id,
|
||||
role: inviteRole.value,
|
||||
expiresAt: Date.now() + 7 * 86400_000,
|
||||
createdBy: "",
|
||||
},
|
||||
...invites.value,
|
||||
];
|
||||
}
|
||||
copyText(`${window.location.origin}${url}`);
|
||||
copiedToken.value = token;
|
||||
window.setTimeout(() => {
|
||||
copiedToken.value = "";
|
||||
}, 2000);
|
||||
await loadInvites();
|
||||
} catch (err) {
|
||||
formError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
inviting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite(inv: GroupInvite): Promise<void> {
|
||||
const url = `${window.location.origin}/admin/invite?token=${inv.token}`;
|
||||
copyText(url);
|
||||
copiedToken.value = inv.token;
|
||||
window.setTimeout(() => {
|
||||
copiedToken.value = "";
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function revoke(inv: GroupInvite): Promise<void> {
|
||||
try {
|
||||
await invitesApi.revoke(inv.token);
|
||||
invites.value = invites.value.filter((i) => i.token !== inv.token);
|
||||
} catch (err) {
|
||||
formError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
function shortToken(token: string): string {
|
||||
return token.slice(0, 8) + "…";
|
||||
}
|
||||
|
||||
function fmtExp(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
111
app/components/RouteCard.vue
Normal file
111
app/components/RouteCard.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<article class="card" :class="{ disabled: !route.enabled }">
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<label v-if="!readonly" class="switch">
|
||||
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
<span v-if="readonly" class="dot" :class="route.enabled ? 'ok' : 'bad'"></span>
|
||||
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
||||
<span class="route-id">{{ route.id }}</span>
|
||||
<span
|
||||
v-for="(tg, i) in route.targets"
|
||||
:key="i"
|
||||
class="badge"
|
||||
:class="tg.platform === 'telegram' ? 'fallback' : 'lang'"
|
||||
>{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}</span
|
||||
>
|
||||
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
||||
<span v-if="route.stop" class="badge stop">{{ t("route.stop") }}</span>
|
||||
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<template v-if="!readonly">
|
||||
<button
|
||||
class="icon-btn"
|
||||
:disabled="atFirst"
|
||||
:title="t('route.moveUp')"
|
||||
@click="$emit('move', route, -1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
:disabled="atLast"
|
||||
:title="t('route.moveDown')"
|
||||
@click="$emit('move', route, 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
:title="t('routeEditor.editTitle')"
|
||||
@click="$emit('edit', route)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
:title="t('routeEditor.close')"
|
||||
@click="$emit('delete', route)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span v-for="(f, i) in route.filters" :key="i" class="chip" :class="{ exclude: f.exclude }">
|
||||
<span class="f-type"
|
||||
>{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span
|
||||
>
|
||||
<span class="f-val">{{ fmtMatch(f.match) }}</span>
|
||||
</span>
|
||||
<span v-if="!route.filters.length" class="chip"
|
||||
><span class="f-type">{{ t("route.noFilters") }}</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="target">
|
||||
<div v-for="(tg, i) in route.targets" :key="i" class="flex flex-wrap gap-x-[18px] gap-y-2">
|
||||
<span
|
||||
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
|
||||
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
|
||||
>
|
||||
<span v-if="tg.platform === 'telegram' && tg.topicId"
|
||||
><b>{{ t("route.topic") }}</b
|
||||
><code>{{ tg.topicId }}</code></span
|
||||
>
|
||||
<span v-else-if="tg.platform !== 'telegram' && tg.threadId"
|
||||
><b>{{ t("route.thread") }}</b
|
||||
><code>{{ tg.threadId }}</code></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Route } from "~/types";
|
||||
import { fmtMatch } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
route: Route;
|
||||
atFirst?: boolean;
|
||||
atLast?: boolean;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", route: Route): void;
|
||||
(e: "edit", route: Route): void;
|
||||
(e: "delete", route: Route): void;
|
||||
(e: "move", route: Route, dir: -1 | 1): void;
|
||||
}>();
|
||||
|
||||
function onToggle(event: Event): void {
|
||||
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
|
||||
emit("toggle", next);
|
||||
}
|
||||
</script>
|
||||
368
app/components/RouteEditor.vue
Normal file
368
app/components/RouteEditor.vue
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
<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">
|
||||
<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 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>
|
||||
<div class="field inline">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>{{ t("routeEditor.enabled") }}</span>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<input v-model="form.fallback" type="checkbox" />
|
||||
<span
|
||||
>{{ t("routeEditor.fallback") }}
|
||||
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<input v-model="form.stop" type="checkbox" />
|
||||
<span
|
||||
>{{ t("routeEditor.stop") }}
|
||||
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("routeEditor.discordRoles") }}
|
||||
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.discordRolesText"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("routeEditor.filters") }}
|
||||
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
|
||||
>
|
||||
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
||||
<select v-model="f.type">
|
||||
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
|
||||
{{ t("filter." + ft) }}
|
||||
</option>
|
||||
</select>
|
||||
<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>
|
||||
<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>
|
||||
<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";
|
||||
|
||||
interface FilterForm extends Filter {
|
||||
matchText: string;
|
||||
}
|
||||
|
||||
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 targetError = ref("");
|
||||
const formError = ref("");
|
||||
|
||||
interface TargetForm extends RouteTarget {
|
||||
platform: "discord" | "telegram";
|
||||
}
|
||||
|
||||
function blankTarget(): TargetForm {
|
||||
return {
|
||||
platform: "discord",
|
||||
channelId: "",
|
||||
threadId: "",
|
||||
chatId: "",
|
||||
topicId: "",
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
enabled: true,
|
||||
fallback: false,
|
||||
stop: false,
|
||||
discordRolesText: "",
|
||||
targets: [] as TargetForm[],
|
||||
filters: [] as FilterForm[],
|
||||
});
|
||||
|
||||
function parseMatch(text: string): string | string[] | null {
|
||||
const parts = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return null;
|
||||
return parts.length === 1 ? parts[0]! : parts;
|
||||
}
|
||||
|
||||
function blankFilter(): FilterForm {
|
||||
return { type: "event", match: "", exclude: false, matchText: "" };
|
||||
}
|
||||
|
||||
function addFilter(): void {
|
||||
form.filters.push(blankFilter());
|
||||
filterError.value = "";
|
||||
}
|
||||
|
||||
function applyTemplate(tmpl: (typeof ROUTE_TEMPLATES)[number]): 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 = "";
|
||||
}
|
||||
|
||||
function addTarget(): void {
|
||||
form.targets.push(blankTarget());
|
||||
targetError.value = "";
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
const r = props.route;
|
||||
form.id = r?.id ?? "";
|
||||
form.name = r?.name ?? "";
|
||||
form.enabled = r?.enabled ?? true;
|
||||
form.fallback = r?.fallback ?? false;
|
||||
form.stop = r?.stop ?? false;
|
||||
form.discordRolesText = r?.discordRoleIds?.length ? r.discordRoleIds.join(", ") : "";
|
||||
form.targets =
|
||||
r && r.targets.length
|
||||
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
|
||||
: [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[];
|
||||
filterError.value = "";
|
||||
targetError.value = "";
|
||||
formError.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
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>
|
||||
172
app/components/SendLogs.vue
Normal file
172
app/components/SendLogs.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="log-toolbar">
|
||||
<span class="kpi-label">{{ t("logs.lastSends", { n: logs.length }) }}</span>
|
||||
<div class="log-filters">
|
||||
<label class="filter-label">{{ t("logs.filterGroup") }}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="selectedGroupId"
|
||||
:disabled="loading"
|
||||
@change="onGroupFilter"
|
||||
>
|
||||
<option value="">{{ t("logs.allGroups") }}</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||
{{ g.name || g.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">
|
||||
{{ t("logs.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-else-if="!loading && !logs.length" class="empty-log">{{ t("logs.empty") }}</p>
|
||||
|
||||
<div class="log-list">
|
||||
<article
|
||||
v-for="l in logs"
|
||||
:key="l.id ?? l.ts + l.routeId"
|
||||
class="log-entry"
|
||||
:class="{ fail: !l.ok, clickable: true }"
|
||||
@click="openDetail(l)"
|
||||
>
|
||||
<div class="log-head">
|
||||
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
|
||||
<span class="log-route">{{ l.routeId }}</span>
|
||||
<span class="log-event">{{ l.event }}</span>
|
||||
<span v-if="l.status" class="log-status" :class="l.ok ? 'ok' : 'bad'">{{
|
||||
l.status
|
||||
}}</span>
|
||||
<span class="log-time">{{ fmtTime(l.ts) }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span v-if="l.repo"
|
||||
><b>{{ t("logs.repo") }}</b
|
||||
><code>{{ l.repo }}</code></span
|
||||
>
|
||||
<span
|
||||
><b>{{ t("logs.target") }}</b
|
||||
><code>{{ l.target }}</code></span
|
||||
>
|
||||
</div>
|
||||
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="detailOpen" class="modal-overlay" @click.self="closeDetail">
|
||||
<div class="log-detail">
|
||||
<div class="detail-head">
|
||||
<h3>{{ t("logs.detailTitle") }}</h3>
|
||||
<button class="icon-btn" @click="closeDetail">✕</button>
|
||||
</div>
|
||||
|
||||
<p v-if="detailLoading" class="empty-log">{{ t("status.loading") }}</p>
|
||||
<p v-else-if="detailError" class="err">{{ detailError }}</p>
|
||||
|
||||
<dl v-else-if="detail" class="detail-grid">
|
||||
<template v-for="row in detailRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>
|
||||
<code v-if="row.code">{{ row.value }}</code>
|
||||
<span v-else>{{ row.value }}</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<p v-else class="err">{{ t("logs.detailMissing") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Group, SendRecord } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { loadById } = useSendLogs();
|
||||
|
||||
const props = defineProps<{
|
||||
logs: SendRecord[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
groups: Group[];
|
||||
selectedGroupId: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
(e: "filter"): void;
|
||||
(e: "update:selectedGroupId", value: string): void;
|
||||
}>();
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<SendRecord | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
|
||||
const detailRows = computed(() => {
|
||||
const l = detail.value;
|
||||
if (!l) return [] as Array<{ label: string; value: string; code?: boolean }>;
|
||||
const rows: Array<{ label: string; value: string; code?: boolean }> = [
|
||||
{ label: t("logs.id"), value: String(l.id ?? "-"), code: true },
|
||||
{ label: t("logs.time"), value: fmtTime(l.ts) },
|
||||
{ label: t("logs.route"), value: l.routeId, code: true },
|
||||
{ label: t("logs.event"), value: l.event, code: true },
|
||||
{ label: t("logs.action"), value: l.action || "-", code: true },
|
||||
{ label: t("logs.actor"), value: l.actor || "-", code: true },
|
||||
{ label: t("logs.deliveryId"), value: l.deliveryId || "-", code: true },
|
||||
{ label: t("logs.repo"), value: l.repo || "-", code: true },
|
||||
{ label: t("logs.target"), value: l.target, code: true },
|
||||
{ label: t("logs.platform"), value: l.platform || "-", code: true },
|
||||
{ label: t("logs.status"), value: l.status != null ? String(l.status) : "-", code: true },
|
||||
{ label: t("logs.messageId"), value: l.messageId || "-", code: true },
|
||||
{ label: t("logs.errorCode"), value: l.errorCode || "-", code: true },
|
||||
{ label: t("logs.duration"), value: l.durationMs != null ? `${l.durationMs} ms` : "-" },
|
||||
{ label: t("logs.attempts"), value: l.attempts != null ? String(l.attempts) : "-" },
|
||||
{ label: t("logs.ok"), value: l.ok ? t("logs.yes") : t("logs.no") },
|
||||
{ label: t("logs.error"), value: l.error || "-" },
|
||||
];
|
||||
if (l.detail && Object.keys(l.detail).length > 0) {
|
||||
rows.push({
|
||||
label: t("logs.detail"),
|
||||
value: JSON.stringify(l.detail, null, 2),
|
||||
code: true,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
emit("refresh");
|
||||
}
|
||||
|
||||
function onGroupFilter(e: Event): void {
|
||||
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
|
||||
emit("filter");
|
||||
}
|
||||
|
||||
async function openDetail(l: SendRecord): Promise<void> {
|
||||
if (l.id == null) return;
|
||||
detailOpen.value = true;
|
||||
detail.value = null;
|
||||
detailError.value = "";
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await loadById(l.id);
|
||||
} catch (err) {
|
||||
detailError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
detailOpen.value = false;
|
||||
detail.value = null;
|
||||
}
|
||||
</script>
|
||||
154
app/components/WebhookPanel.vue
Normal file
154
app/components/WebhookPanel.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<template>
|
||||
<section class="members-panel webhook-panel">
|
||||
<div class="panel-head">
|
||||
<h3>{{ t("webhook.title") }}</h3>
|
||||
<span class="lbl-note">{{ t("webhook.note") }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!info" class="empty-log">{{ t("webhook.empty") }}</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="wh-row">
|
||||
<span class="wh-label">{{ t("webhook.url") }}</span>
|
||||
<code class="wh-value">{{ info.url }}</code>
|
||||
<button class="btn btn-ghost btn-sm" @click="copy(info.url, 'url')">
|
||||
{{ copiedUrl ? t("webhook.copied") : t("webhook.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wh-row">
|
||||
<span class="wh-label">{{ t("webhook.secret") }}</span>
|
||||
<code class="wh-value">{{
|
||||
info.secret ? info.secret : info.hasSecret ? maskedSecret : t("webhook.noSecret")
|
||||
}}</code>
|
||||
<button
|
||||
v-if="info.secret"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="copy(info.secret!, 'secret')"
|
||||
>
|
||||
{{ copiedSecret ? t("webhook.copied") : t("webhook.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="info.hasSecret && !info.secret" class="hint">{{ t("webhook.secretHidden") }}</p>
|
||||
|
||||
<div class="wh-actions">
|
||||
<button class="btn btn-accent btn-sm" :disabled="busy" @click="onRegenerate">
|
||||
{{ info.hasSecret ? t("webhook.regenerate") : t("webhook.generate") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="info.hasSecret"
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="busy"
|
||||
@click="onDisable"
|
||||
>
|
||||
{{ t("webhook.disable") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="wh-usage">
|
||||
<summary>{{ t("webhook.usageTitle") }}</summary>
|
||||
<p class="hint">{{ t("webhook.usageGitHub") }}</p>
|
||||
<p class="hint">{{ t("webhook.usageGitea") }}</p>
|
||||
<p class="hint">{{ t("webhook.usageCustom") }}</p>
|
||||
<pre class="wh-code">{{ customExample }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<div class="err">{{ error }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useWebhookApi, type GroupWebhookInfo } from "~/composables/useWebhook";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ groupId: string; canEdit: boolean }>();
|
||||
|
||||
const api = useWebhookApi();
|
||||
const info = ref<GroupWebhookInfo | null>(null);
|
||||
const copiedUrl = ref(false);
|
||||
const copiedSecret = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const maskedSecret = "••••••••••••••••";
|
||||
const customExample = [
|
||||
'curl -X POST "$URL" \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "X-WebHooker-Signature: sha256=$(hmac-sha256 "$BODY" "$SECRET")" \\',
|
||||
" -d '{",
|
||||
' "title": "Deploy failed",',
|
||||
' "description": "Prod rollout failed at 12:03 UTC",',
|
||||
' "color": "red",',
|
||||
' "repo": "acme/widget",',
|
||||
' "url": "https://ci.example.com/runs/42",',
|
||||
' "fields": [{ "name": "Env", "value": "prod", "inline": true }]',
|
||||
" }'",
|
||||
].join("\n");
|
||||
|
||||
watch(
|
||||
() => props.groupId,
|
||||
() => {
|
||||
if (!props.canEdit) return;
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
error.value = "";
|
||||
try {
|
||||
info.value = await api.info(props.groupId);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
info.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
info.value = await api.regenerate(props.groupId);
|
||||
copiedSecret.value = false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisable(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.disable(props.groupId);
|
||||
info.value = { url: info.value?.url ?? "", hasSecret: false };
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text: string, which: "url" | "secret"): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (which === "url") {
|
||||
copiedUrl.value = true;
|
||||
window.setTimeout(() => {
|
||||
copiedUrl.value = false;
|
||||
}, 1500);
|
||||
} else {
|
||||
copiedSecret.value = true;
|
||||
window.setTimeout(() => {
|
||||
copiedSecret.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
} catch {
|
||||
// ignore clipboard failures
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue