feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3

This commit is contained in:
RhenCloud 2026-08-13 17:43:33 +08:00
parent f4959eebf8
commit b139712a91
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
166 changed files with 19790 additions and 5539 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>