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
3
app/app.vue
Normal file
3
app/app.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
833
app/assets/css/main.css
Normal file
833
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Theme tokens — RGB triplets so Tailwind opacity modifiers (bg-accent/10)
|
||||
* work against them; values switch automatically with prefers-color-scheme
|
||||
* (unless an explicit data-theme="light" is set on <html>).
|
||||
* ------------------------------------------------------------------------- */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-ui: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--header-bg: rgba(255, 255, 255, 0.82);
|
||||
--scrim: rgba(15, 23, 42, 0.4);
|
||||
--knob: #ffffff;
|
||||
--dot: rgba(15, 23, 42, 0.05);
|
||||
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
|
||||
--bg: 246 247 249;
|
||||
--surface: 255 255 255;
|
||||
--surface-2: 243 244 246;
|
||||
--surface-3: 236 238 242;
|
||||
--border: 230 232 236;
|
||||
--border-strong: 212 216 222;
|
||||
--text: 15 23 42;
|
||||
--muted: 91 100 114;
|
||||
--faint: 154 163 178;
|
||||
--accent: 79 70 229;
|
||||
--accent-strong: 67 56 202;
|
||||
--accent-dim: 238 242 255;
|
||||
--accent-text: 55 48 163;
|
||||
--accent-border: 224 231 255;
|
||||
--ok: 22 163 74;
|
||||
--ok-dim: 236 253 243;
|
||||
--warn: 180 83 9;
|
||||
--warn-dim: 254 243 199;
|
||||
--bad: 220 38 38;
|
||||
--bad-dim: 254 242 242;
|
||||
--body-text: 48 54 61;
|
||||
--code-bg: 240 241 243;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--header-bg: rgba(15, 20, 28, 0.82);
|
||||
--scrim: rgba(2, 6, 12, 0.6);
|
||||
--knob: #dfe4ee;
|
||||
--dot: rgba(148, 163, 184, 0.08);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--bg: 11 15 23;
|
||||
--surface: 19 26 36;
|
||||
--surface-2: 26 34 48;
|
||||
--surface-3: 35 45 61;
|
||||
--border: 39 49 67;
|
||||
--border-strong: 56 70 96;
|
||||
--text: 230 233 239;
|
||||
--muted: 154 166 184;
|
||||
--faint: 107 119 137;
|
||||
--accent: 99 102 241;
|
||||
--accent-strong: 129 140 248;
|
||||
--accent-dim: 28 33 64;
|
||||
--accent-text: 199 210 254;
|
||||
--accent-border: 47 55 102;
|
||||
--ok: 34 197 94;
|
||||
--ok-dim: 16 36 26;
|
||||
--warn: 251 191 36;
|
||||
--warn-dim: 42 36 16;
|
||||
--bad: 240 82 82;
|
||||
--bad-dim: 42 22 24;
|
||||
--body-text: 195 202 214;
|
||||
--code-bg: 26 34 48;
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-bg text-text font-ui text-sm leading-[1.55] antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(var(--dot) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent 40%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---- Layout ---- */
|
||||
.shell {
|
||||
@apply relative z-[1] min-h-screen;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between gap-4 px-8 py-3.5 border-b border-border;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@apply grid place-items-center h-[38px] w-[38px] rounded-[10px] bg-accent text-white font-extrabold text-[15px] tracking-wide;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
@apply text-lg font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
@apply mt-px block text-[10px] font-semibold uppercase tracking-[2.5px] text-faint;
|
||||
}
|
||||
|
||||
.main {
|
||||
@apply relative z-[1] mx-auto max-w-[1040px] px-8 pb-24 pt-8 max-sm:px-4 max-sm:pb-20 max-sm:pt-5;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
@apply inline-flex cursor-pointer items-center justify-center gap-2 rounded-sm border border-border-strong bg-surface px-4 py-2 font-ui text-[13px] font-semibold text-text no-underline transition-[border-color,background,color,transform] duration-150;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@apply border-accent text-accent;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
@apply translate-y-px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@apply border-accent bg-accent text-white;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
@apply border-accent-strong bg-accent-strong text-white;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply border-transparent bg-transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
@apply border-transparent bg-surface-2 text-text;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-sm;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply grid h-[30px] w-[30px] place-items-center cursor-pointer rounded-sm border border-border bg-transparent text-[13px] text-muted transition-all duration-150;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
@apply border-accent bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
@apply cursor-not-allowed opacity-35;
|
||||
}
|
||||
|
||||
.icon-btn:disabled:hover {
|
||||
@apply border-border bg-transparent text-muted;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
@apply border-bad bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
@apply animate-rise rounded border border-border bg-surface p-5 transition-[border-color,transform] duration-150;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-border-strong;
|
||||
}
|
||||
|
||||
.card.disabled {
|
||||
@apply opacity-55;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
@apply flex items-start justify-between gap-3.5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
@apply text-base font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.route-id {
|
||||
@apply font-mono text-xs text-faint;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-block rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-semibold text-muted;
|
||||
}
|
||||
|
||||
.badge.lang {
|
||||
@apply bg-accent-dim text-accent-text;
|
||||
}
|
||||
|
||||
.badge.fallback {
|
||||
@apply bg-warn-dim text-warn;
|
||||
}
|
||||
|
||||
.badge.stop {
|
||||
@apply bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
@apply flex flex-shrink-0 gap-1.5;
|
||||
}
|
||||
|
||||
/* ---- Toggle switch ---- */
|
||||
.switch {
|
||||
@apply relative h-[22px] w-[38px] flex-shrink-0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
@apply h-0 w-0 opacity-0;
|
||||
}
|
||||
|
||||
.switch .track {
|
||||
@apply absolute inset-0 cursor-pointer rounded-full border border-border-strong bg-surface-3 transition-[background,border-color] duration-200;
|
||||
}
|
||||
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--knob);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + .track {
|
||||
@apply border-accent bg-accent;
|
||||
}
|
||||
|
||||
.switch input:checked + .track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ---- Filter chips ---- */
|
||||
.chips {
|
||||
@apply mt-3.5 flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2.5 py-1 text-xs;
|
||||
}
|
||||
|
||||
.chip .f-type {
|
||||
@apply text-[10px] font-bold uppercase tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.chip .f-val {
|
||||
@apply font-medium text-text;
|
||||
}
|
||||
|
||||
.chip.exclude {
|
||||
@apply bg-bad-dim;
|
||||
}
|
||||
|
||||
.chip.exclude .f-type {
|
||||
@apply text-bad;
|
||||
}
|
||||
|
||||
/* ---- Targets / meta ---- */
|
||||
.target {
|
||||
@apply mt-3 flex flex-wrap gap-x-[18px] gap-y-2 text-xs text-muted;
|
||||
}
|
||||
|
||||
.target b {
|
||||
@apply mr-1 font-semibold tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.target code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-px font-mono text-text;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@apply py-[72px] text-center text-muted;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
/* ---- Login ---- */
|
||||
.login {
|
||||
@apply grid min-h-[62vh] place-items-center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@apply max-w-[420px] rounded-2xl border border-border bg-surface px-[52px] py-12 text-center;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
@apply mb-2.5 text-[22px] font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
@apply mb-6 leading-relaxed text-muted;
|
||||
}
|
||||
|
||||
.login-card code {
|
||||
@apply rounded-[5px] bg-accent-dim px-1.5 py-px font-mono text-accent;
|
||||
}
|
||||
|
||||
/* ---- Drawer / overlay ---- */
|
||||
.overlay {
|
||||
@apply fixed inset-0 z-40 backdrop-blur-[2px];
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.editor {
|
||||
@apply fixed bottom-0 right-0 top-0 z-50 flex w-[min(520px,100vw)] flex-col border-l border-border bg-surface shadow-drawer;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
@apply flex items-center justify-between border-b border-border px-6 py-5;
|
||||
}
|
||||
|
||||
.editor-head h2 {
|
||||
@apply text-lg font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
@apply flex-1 overflow-y-auto px-6 py-[22px];
|
||||
}
|
||||
|
||||
.editor-foot {
|
||||
@apply flex justify-end gap-2 border-t border-border px-6 py-4;
|
||||
}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.field {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
@apply mb-1.5 block text-[11px] font-bold uppercase tracking-[1.2px] text-faint;
|
||||
}
|
||||
|
||||
.lbl-note {
|
||||
@apply font-medium normal-case tracking-normal text-faint;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@apply mt-1.5 text-[11px] text-faint;
|
||||
}
|
||||
|
||||
.err {
|
||||
@apply mt-2 min-h-4 text-xs text-bad;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-sm border border-border-strong bg-surface px-3 py-2.5 font-ui text-[13.5px] text-text transition-[border-color,box-shadow] duration-150;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@apply border-accent outline-none ring-[3px] ring-accent-dim;
|
||||
}
|
||||
|
||||
.select {
|
||||
@apply w-full cursor-pointer rounded-sm border border-border-strong bg-surface px-3 py-2.5 font-ui text-[13.5px] text-text outline-none;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
@apply border-accent ring-[3px] ring-accent-dim;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
@apply grid grid-cols-2 gap-3 max-sm:grid-cols-1;
|
||||
}
|
||||
|
||||
.inline {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.inline input[type="checkbox"] {
|
||||
@apply h-4 w-4 accent-accent;
|
||||
}
|
||||
|
||||
.inline span {
|
||||
@apply text-[12.5px] font-medium text-muted;
|
||||
}
|
||||
|
||||
.templates {
|
||||
@apply flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
@apply cursor-pointer rounded-full border border-border-strong bg-surface-2 px-3 py-1 font-ui text-xs font-semibold text-muted transition-[border-color,color,background] duration-150;
|
||||
}
|
||||
|
||||
.template-chip:hover {
|
||||
@apply border-accent text-accent;
|
||||
}
|
||||
|
||||
.template-chip.active {
|
||||
@apply border-accent bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
@apply mb-2 grid grid-cols-[130px_1fr_auto_auto] items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5 max-sm:grid-cols-[100px_1fr_auto];
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
@apply w-full rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||
}
|
||||
|
||||
.filter-row input[type="text"] {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
@apply mb-2 grid grid-cols-[minmax(130px,1fr)_auto] items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5 max-sm:grid-cols-[1fr_auto];
|
||||
grid-template-areas:
|
||||
"select del"
|
||||
"in1 in2";
|
||||
}
|
||||
|
||||
.target-row select {
|
||||
@apply w-full rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text;
|
||||
grid-area: select;
|
||||
}
|
||||
|
||||
.target-row input.tg-in1 {
|
||||
grid-area: in1;
|
||||
}
|
||||
|
||||
.target-row input.tg-in2 {
|
||||
grid-area: in2;
|
||||
}
|
||||
|
||||
.target-row > .icon-btn {
|
||||
grid-area: del;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.add-filter {
|
||||
@apply mt-1 w-full justify-center;
|
||||
}
|
||||
|
||||
/* ---- Toasts ---- */
|
||||
.toasts {
|
||||
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply pointer-events-auto cursor-pointer rounded-full bg-text px-[18px] py-2.5 text-[13px] font-semibold text-white;
|
||||
}
|
||||
|
||||
.toast.ok {
|
||||
@apply bg-ok;
|
||||
}
|
||||
|
||||
.toast.bad {
|
||||
@apply bg-bad;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
@apply mb-5 inline-flex gap-0.5 rounded-[10px] border border-border bg-surface-2 p-1;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply cursor-pointer rounded-[7px] border-none bg-transparent px-4 py-1.5 font-ui text-[13px] font-semibold text-muted transition-[background,color] duration-150;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
@apply text-text;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@apply bg-surface text-accent shadow-[0_1px_3px_rgba(15,23,42,0.08)];
|
||||
}
|
||||
|
||||
/* ---- Toolbar / KPIs ---- */
|
||||
.toolbar {
|
||||
@apply mb-[18px] flex items-center justify-between;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
@apply text-[11px] font-bold uppercase tracking-[1.5px] text-faint;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
@apply ml-2 text-2xl font-extrabold tracking-tight text-accent;
|
||||
}
|
||||
|
||||
.status {
|
||||
@apply flex items-center gap-2 text-xs font-medium text-muted;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@apply h-2 w-2 rounded-full bg-border-strong;
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
@apply bg-ok;
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
@apply bg-bad;
|
||||
}
|
||||
|
||||
/* ---- Logs / audit ---- */
|
||||
.log-toolbar {
|
||||
@apply mb-3.5 flex flex-wrap items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@apply text-[11px] font-bold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
@apply cursor-pointer rounded border border-border bg-surface px-2 py-1 text-[13px] text-text outline-none;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
@apply border-accent;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
@apply flex flex-col gap-2.5;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
@apply rounded border border-border border-l-[3px] border-l-ok bg-surface p-4 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
@apply border-border-strong;
|
||||
}
|
||||
|
||||
.log-entry.fail {
|
||||
@apply border-l-bad;
|
||||
}
|
||||
|
||||
.log-entry.clickable {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
@apply flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
@apply text-sm font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.log-event {
|
||||
@apply rounded-full bg-accent-dim px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
@apply ml-auto text-xs font-medium text-faint;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
@apply mt-2 flex flex-wrap gap-x-[18px] gap-y-1 text-xs text-muted;
|
||||
}
|
||||
|
||||
.log-meta b {
|
||||
@apply mr-1 font-semibold tracking-wider text-faint;
|
||||
}
|
||||
|
||||
.log-meta code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-px font-mono text-text;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
@apply mt-2 rounded-sm bg-bad-dim px-2.5 py-2 text-xs font-medium break-all text-bad;
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
@apply py-14 text-center text-faint;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
@apply rounded-full px-2.5 py-0.5 text-[11px] font-bold tracking-wide;
|
||||
}
|
||||
|
||||
.log-status.ok {
|
||||
@apply bg-ok-dim text-ok;
|
||||
}
|
||||
|
||||
.log-status.bad {
|
||||
@apply bg-bad-dim text-bad;
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
@apply max-h-[82vh] w-[calc(100vw-48px)] max-w-[720px] overflow-y-auto rounded border border-border bg-surface p-[22px] shadow-modal;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-[60] flex items-center justify-center p-6 backdrop-blur-[2px];
|
||||
background: var(--scrim);
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
@apply mb-3.5 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.detail-head h3 {
|
||||
@apply text-[15px] tracking-wide;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
@apply grid grid-cols-[minmax(120px,30%)_1fr] gap-x-4 gap-y-1.5 text-[13px];
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
@apply pt-1.5 text-[11px] font-semibold tracking-wide text-faint;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
@apply break-all pt-1.5 text-text;
|
||||
}
|
||||
|
||||
.detail-grid code {
|
||||
@apply rounded-[5px] bg-surface-2 px-1.5 py-0.5 font-mono text-xs;
|
||||
}
|
||||
|
||||
.detail-grid dd code {
|
||||
@apply whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- Members / invites ---- */
|
||||
.members-panel {
|
||||
@apply mt-7 rounded border border-border bg-surface p-5;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@apply mb-3 flex flex-wrap items-baseline gap-2.5;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
@apply text-sm tracking-wide;
|
||||
}
|
||||
|
||||
.members-panel h4 {
|
||||
@apply my-3.5 mb-2 text-xs;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
@apply m-0 mb-3 flex list-none flex-col gap-2 p-0;
|
||||
}
|
||||
|
||||
.member-row,
|
||||
.invite-row {
|
||||
@apply flex flex-wrap items-center gap-2.5 rounded-sm border border-border bg-surface-2 px-3 py-2;
|
||||
}
|
||||
|
||||
.member-login {
|
||||
@apply min-w-[120px] flex-1 text-[13px] font-semibold;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
@apply rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.role-pill.owner {
|
||||
@apply bg-accent-dim text-accent;
|
||||
}
|
||||
|
||||
.role-pill.admin {
|
||||
@apply bg-ok-dim text-ok;
|
||||
}
|
||||
|
||||
.role-pill.viewer {
|
||||
@apply border border-border bg-surface text-faint;
|
||||
}
|
||||
|
||||
.member-add,
|
||||
.invite-create {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply min-w-[200px] rounded border border-border bg-surface px-2.5 py-1.5 text-[13px] text-text outline-none;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
@apply border-accent;
|
||||
}
|
||||
|
||||
.invite-box {
|
||||
@apply mt-3.5 border-t border-dashed border-border pt-1.5;
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
@apply m-2.5 mb-0 flex list-none flex-col gap-2 p-0;
|
||||
}
|
||||
|
||||
.invite-token {
|
||||
@apply font-mono text-xs text-muted;
|
||||
}
|
||||
|
||||
.invite-exp {
|
||||
@apply text-xs text-faint;
|
||||
}
|
||||
|
||||
/* ---- Legal pages ---- */
|
||||
.legal-body h2 {
|
||||
@apply mb-2 mt-7 text-[17px] font-bold;
|
||||
}
|
||||
|
||||
.legal-body p,
|
||||
.legal-body li {
|
||||
@apply text-[15px] text-body-text;
|
||||
}
|
||||
|
||||
.legal-body a {
|
||||
@apply text-accent;
|
||||
}
|
||||
|
||||
.legal-body ul {
|
||||
@apply pl-5;
|
||||
}
|
||||
|
||||
.legal-body code {
|
||||
@apply rounded-[6px] border border-border bg-code-bg px-1.5 py-px font-mono text-[13px];
|
||||
}
|
||||
|
||||
/* ---- Webhook panel ---- */
|
||||
.webhook-panel .wh-row {
|
||||
@apply mb-2 flex flex-wrap items-center gap-2.5;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-label {
|
||||
@apply min-w-[52px] text-[11px] font-bold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-value {
|
||||
@apply min-w-[180px] flex-1 rounded-sm border border-border bg-surface-2 px-2.5 py-1.5 text-[12.5px] break-all;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-actions {
|
||||
@apply mt-1.5 flex gap-2;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage {
|
||||
@apply mt-3.5;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage summary {
|
||||
@apply cursor-pointer text-xs text-muted;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-code {
|
||||
@apply mt-2.5 overflow-x-auto rounded-sm border border-border bg-surface-2 p-3 text-[11.5px] leading-relaxed text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Vue transition glue (lifecycle classes) ---- */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition:
|
||||
opacity 0.22s,
|
||||
transform 0.22s;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
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>
|
||||
35
app/composables/useAudit.ts
Normal file
35
app/composables/useAudit.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { AuditEntry } from "~/types";
|
||||
|
||||
export function useAuditApi() {
|
||||
const entries = ref<AuditEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(limit = 50, groupId?: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
let url = `/admin/api/audit?limit=${limit}`;
|
||||
if (groupId) url += `&groupId=${encodeURIComponent(groupId)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { audit?: AuditEntry[] };
|
||||
entries.value = data.audit ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, loading, needLogin, error, load };
|
||||
}
|
||||
51
app/composables/useGroupRoutes.ts
Normal file
51
app/composables/useGroupRoutes.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Route } from "~/types";
|
||||
|
||||
export function useGroupRoutesApi() {
|
||||
const routes = ref<Route[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(groupId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/routes`, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { routes?: Route[] };
|
||||
routes.value = data.routes ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(groupId: string, next: Route[]): Promise<void> {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/routes`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ routes: next }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
routes.value = next;
|
||||
}
|
||||
|
||||
return { routes, loading, needLogin, error, load, save };
|
||||
}
|
||||
105
app/composables/useGroups.ts
Normal file
105
app/composables/useGroups.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { Group, GroupRole } from "~/types";
|
||||
|
||||
export function useGroupsApi() {
|
||||
const groups = ref<Group[]>([]);
|
||||
const isSuper = ref(false);
|
||||
/** groupId → role of the signed-in user (absent for super admins). */
|
||||
const roles = ref<Record<string, GroupRole>>({});
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function roleOf(groupId: string): GroupRole | undefined {
|
||||
return isSuper.value ? "owner" : roles.value[groupId];
|
||||
}
|
||||
|
||||
function canEditGroup(groupId: string): boolean {
|
||||
const r = roleOf(groupId);
|
||||
return isSuper.value || r === "owner";
|
||||
}
|
||||
|
||||
function canEditRoutes(groupId: string): boolean {
|
||||
const r = roleOf(groupId);
|
||||
return isSuper.value || r === "owner" || r === "admin";
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
const res = await fetch("/admin/api/groups", {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
groups?: Group[];
|
||||
isSuper?: boolean;
|
||||
roles?: Record<string, GroupRole>;
|
||||
};
|
||||
groups.value = data.groups ?? [];
|
||||
isSuper.value = data.isSuper ?? false;
|
||||
roles.value = data.roles ?? {};
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(next: Group[]): Promise<void> {
|
||||
const res = await fetch("/admin/api/groups", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ groups: next }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
groups.value = next;
|
||||
}
|
||||
|
||||
/** Rename a group; routes, webhook secret and invites follow automatically. */
|
||||
async function rename(oldId: string, newId: string): Promise<void> {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(oldId)}/rename`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ newId }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
isSuper,
|
||||
roles,
|
||||
roleOf,
|
||||
canEditGroup,
|
||||
canEditRoutes,
|
||||
loading,
|
||||
needLogin,
|
||||
error,
|
||||
load,
|
||||
save,
|
||||
rename,
|
||||
};
|
||||
}
|
||||
547
app/composables/useI18n.ts
Normal file
547
app/composables/useI18n.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
export type Locale = "en" | "zh";
|
||||
|
||||
const STORAGE_KEY = "wh-locale";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const en: Dict = {
|
||||
"app.tagline": "CONFIG CONSOLE",
|
||||
"app.newRoute": "+ New Route",
|
||||
"app.newGroup": "+ New Group",
|
||||
"app.signOut": "Sign out",
|
||||
"app.langToggle": "中文",
|
||||
"login.title": "Config Console",
|
||||
"login.forbidden": "Access denied — this GitHub account is not an admin or group admin.",
|
||||
"login.prompt": "Sign in with GitHub to manage routes.",
|
||||
"login.button": "Sign in with GitHub",
|
||||
"tab.groups": "Groups",
|
||||
"tab.logs": "Send Logs",
|
||||
"tab.audit": "Audit Log",
|
||||
"status.loading": "loading",
|
||||
"status.connected": "connected",
|
||||
"kpi.groups": "GROUPS",
|
||||
"kpi.routes": "ROUTES",
|
||||
"role.owner": "Owner",
|
||||
"role.admin": "Admin",
|
||||
"role.viewer": "Viewer",
|
||||
"role.badge": "You: {role}",
|
||||
"roles.owner": "Owner",
|
||||
"roles.admin": "Admin",
|
||||
"roles.viewer": "Viewer",
|
||||
"members.title": "Members",
|
||||
"members.note": "(role: owner = full control · admin = edit routes · viewer = read-only)",
|
||||
"members.empty": "No members yet — add the first owner or share an invite link.",
|
||||
"members.add": "Add member",
|
||||
"members.addPlaceholder": "GitHub login",
|
||||
"members.addBtn": "Add",
|
||||
"members.role": "Role",
|
||||
"members.you": "(you)",
|
||||
"members.remove": "Remove",
|
||||
"members.invites": "Invite links",
|
||||
"members.inviteNote": "(single-use · expires in 7 days)",
|
||||
"members.inviteRole": "Invite as",
|
||||
"members.createInvite": "Create invite link",
|
||||
"members.copyLink": "Copy",
|
||||
"members.copied": "Copied!",
|
||||
"members.revoke": "Revoke",
|
||||
"members.noInvites": "No pending invites.",
|
||||
"members.errSelfOwner": "You must remain an owner of this group.",
|
||||
"members.errOwner": "A group needs at least one owner.",
|
||||
"members.errLastOwner": "Cannot remove the last owner.",
|
||||
"members.inviteOk": "Invite accepted — welcome!",
|
||||
"members.inviteBad": "This invite is invalid or expired.",
|
||||
"install.ok":
|
||||
"GitHub App installation registered — the group has been created and bound to this installation.",
|
||||
"webhook.title": "Webhook endpoint",
|
||||
"webhook.note": "(per-group URL + secret)",
|
||||
"webhook.empty": "Not configured. Generate a secret to enable the group's own webhook endpoint.",
|
||||
"webhook.url": "URL",
|
||||
"webhook.secret": "Secret",
|
||||
"webhook.noSecret": "— no secret configured —",
|
||||
"webhook.secretHidden":
|
||||
"The secret is only shown once after generating/regenerating. Regenerate to reveal a new one.",
|
||||
"webhook.copy": "Copy",
|
||||
"webhook.copied": "Copied!",
|
||||
"webhook.generate": "Generate secret",
|
||||
"webhook.regenerate": "Regenerate",
|
||||
"webhook.disable": "Disable",
|
||||
"webhook.usageTitle": "How to use",
|
||||
"webhook.usageGitHub":
|
||||
"GitHub: repo/org Settings → Webhooks → Add webhook → payload URL = the URL above, Content type = application/json, Secret = the secret above.",
|
||||
"webhook.usageGitea": "Gitea: repo Settings → Webhooks → Add Webhook → same URL + secret.",
|
||||
"webhook.usageCustom":
|
||||
"Custom: POST any JSON signed with X-WebHooker-Signature (sha256 HMAC). Create a route with event = custom to receive it:",
|
||||
"groups.empty": "No groups yet. Groups scope routes by org/user and delegate access.",
|
||||
"groups.createFirst": "Create your first group",
|
||||
"groups.admins": "ADMINS",
|
||||
"groups.owners": "OWNERS",
|
||||
"groups.members": "MEMBERS",
|
||||
"groups.any": "any",
|
||||
"groups.open": "Open →",
|
||||
"group.back": "← Groups",
|
||||
"group.routesIn": "Routes in {name}",
|
||||
"routes.emptyGroup": "No routes in this group yet.",
|
||||
"routes.createFirst": "Create your first route",
|
||||
"route.untitled": "(untitled)",
|
||||
"route.noFilters": "no filters",
|
||||
"route.channel": "CHANNEL",
|
||||
"route.thread": "THREAD",
|
||||
"route.chat": "CHAT",
|
||||
"route.topic": "TOPIC",
|
||||
"route.fallback": "fallback",
|
||||
"route.stop": "stop",
|
||||
"route.moveUp": "Move up",
|
||||
"route.moveDown": "Move down",
|
||||
"filter.event": "Event",
|
||||
"filter.repo": "Repo",
|
||||
"filter.actor": "Actor",
|
||||
"filter.action": "Action",
|
||||
"filter.branch": "Branch",
|
||||
"filter.keyword": "Keyword",
|
||||
"routeEditor.editTitle": "Edit route",
|
||||
"routeEditor.newTitle": "New route",
|
||||
"routeEditor.close": "Close",
|
||||
"routeEditor.templates": "Templates",
|
||||
"routeEditor.templatesNote": "(click to prefill filters)",
|
||||
"templates.push": "Push",
|
||||
"templates.pullRequest": "Pull Request",
|
||||
"templates.issues": "Issues",
|
||||
"templates.release": "Release",
|
||||
"templates.workflow": "Workflow",
|
||||
"templates.checks": "Check Suite",
|
||||
"templates.deployment": "Deployment",
|
||||
"templates.comments": "Comments",
|
||||
"templates.reviews": "Review",
|
||||
"templates.starFork": "Star / Fork",
|
||||
"templates.createDelete": "Create / Delete",
|
||||
"templates.member": "Member",
|
||||
"templates.commitComment": "Commit Comment",
|
||||
"templates.customWebhook": "Custom Webhook",
|
||||
"routeEditor.name": "Name",
|
||||
"routeEditor.namePlaceholder": "My Route",
|
||||
"routeEditor.id": "ID",
|
||||
"routeEditor.idHint": "Unique, use a-z / 0-9 / dashes",
|
||||
"routeEditor.enabled": "Route enabled",
|
||||
"routeEditor.fallback": "Fallback route",
|
||||
"routeEditor.fallbackHint": "Only fires when no other route matched; filters still apply",
|
||||
"routeEditor.stop": "Stop on match",
|
||||
"routeEditor.stopHint": "When matched, no further routes are evaluated for this event",
|
||||
"routeEditor.filters": "Filters",
|
||||
"routeEditor.filtersNote": "(all must match · AND)",
|
||||
"routeEditor.matchPlaceholder": "match value",
|
||||
"routeEditor.not": "NOT",
|
||||
"routeEditor.addFilter": "+ Add filter",
|
||||
"routeEditor.discordRoles": "Discord role mentions",
|
||||
"routeEditor.discordRolesNote": "(optional · comma-separated)",
|
||||
"routeEditor.discordRolesPlaceholder": "123456789012345678, 987654321098765432",
|
||||
"routeEditor.discordRolesHint":
|
||||
"When this route fires, the listed roles are pinged in Discord targets.",
|
||||
"routeEditor.channel": "Channel ID",
|
||||
"routeEditor.channelPlaceholder": "Discord channel ID",
|
||||
"routeEditor.thread": "Thread ID",
|
||||
"routeEditor.threadNote": "(optional)",
|
||||
"routeEditor.threadPlaceholder": "Optional thread ID",
|
||||
"routeEditor.platform": "Platform",
|
||||
"routeEditor.chat": "Chat ID",
|
||||
"routeEditor.chatPlaceholder": "Telegram group chat ID",
|
||||
"routeEditor.topic": "Topic ID",
|
||||
"routeEditor.topicNote": "(optional)",
|
||||
"routeEditor.topicPlaceholder": "Optional topic (message_thread_id)",
|
||||
"routeEditor.errChat": "Target {n} chat ID is required",
|
||||
"routeEditor.targets": "Targets",
|
||||
"routeEditor.targetsNote": "(one or more destinations)",
|
||||
"routeEditor.addTarget": "+ Add target",
|
||||
"routeEditor.errTargets": "Add at least one target",
|
||||
"routeEditor.cancel": "Cancel",
|
||||
"routeEditor.save": "Save route",
|
||||
"routeEditor.errFilterMatch": "Filter {n} needs a match value",
|
||||
"routeEditor.errAddFilter": "Add at least one filter",
|
||||
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||
"routeEditor.errName": "Name is required",
|
||||
"routeEditor.errChannel": "Target {n} channel ID is required",
|
||||
"groupEditor.editTitle": "Edit group",
|
||||
"groupEditor.newTitle": "New group",
|
||||
"groupEditor.close": "Close",
|
||||
"groupEditor.name": "Name",
|
||||
"groupEditor.namePlaceholder": "My Team",
|
||||
"groupEditor.id": "ID",
|
||||
"groupEditor.idPlaceholder": "my-team",
|
||||
"groupEditor.idHint": "Unique, use a-z / 0-9 / dashes",
|
||||
"groupEditor.renameHint":
|
||||
"Changing the id also updates routes, webhook secrets and invites that reference this group.",
|
||||
"groupEditor.language": "Message language",
|
||||
"groupEditor.langPlaceholder": "en",
|
||||
"groupEditor.langHint": "en or zh; custom via KV i18n:<lang>",
|
||||
"groupEditor.emojiNote": "(default on)",
|
||||
"groupEditor.emojiLabel": "Show emojis in messages",
|
||||
"groupEditor.admins": "Group admins",
|
||||
"groupEditor.adminsNote": "(GitHub login or user id, comma-separated)",
|
||||
"groupEditor.adminsPlaceholder": "octocat, 12345",
|
||||
"groupEditor.adminsHint": "These users can view logs and edit this group's routes.",
|
||||
"groupEditor.owners": "Owner scope",
|
||||
"groupEditor.ownersNote": "(GitHub org / user logins, comma-separated)",
|
||||
"groupEditor.ownersSuperOnly": "(only super admins may change)",
|
||||
"groupEditor.ownersPlaceholder": "my-org, some-user",
|
||||
"groupEditor.ownersHint":
|
||||
"Only webhook events from these orgs/users enter this group's routes. Leave empty for no restriction.",
|
||||
"groupEditor.membersNote": "Members",
|
||||
"groupEditor.membersHint": "(owner / admin / viewer)",
|
||||
"groupEditor.membersGoPanel": "Manage members and invite links from the group page.",
|
||||
"groupEditor.providers": "Source platforms",
|
||||
"groupEditor.providersNote": "(leave both unchecked = all)",
|
||||
"groupEditor.providersHint":
|
||||
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
|
||||
"groupEditor.installationId": "GitHub App installation ID",
|
||||
"groupEditor.installationIdNote": "(optional)",
|
||||
"groupEditor.installationIdPlaceholder": "e.g. 12345678",
|
||||
"groupEditor.installationIdHint":
|
||||
"Bind this group to one GitHub App installation (the org/user that installed the app). Only events from that installation enter this group's routes; leave empty to allow any installation (still filtered by Owner scope).",
|
||||
"groupEditor.errInstallationId": "Installation ID must be a positive integer",
|
||||
"groupEditor.emoji": "Show emojis in messages",
|
||||
"groupEditor.logTarget": "Webhook log channel",
|
||||
"groupEditor.logTargetNote": "(optional)",
|
||||
"groupEditor.logDisabled": "— Disabled —",
|
||||
"groupEditor.logTargetHint":
|
||||
"A Discord channel/thread or Telegram chat/topic that receives a summary of every webhook this group's routes dispatch.",
|
||||
"groupEditor.errLogChannel": "Log channel ID is required when Discord is selected",
|
||||
"groupEditor.errLogChat": "Log chat ID is required when Telegram is selected",
|
||||
"groupEditor.cancel": "Cancel",
|
||||
"groupEditor.save": "Save group",
|
||||
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||
"groupEditor.errName": "Name is required",
|
||||
"logs.lastSends": "LAST {n} SENDS",
|
||||
"logs.refresh": "⟳ Refresh",
|
||||
"logs.empty": "No send records yet — trigger a webhook to see results.",
|
||||
"logs.repo": "REPO",
|
||||
"logs.target": "TARGET",
|
||||
"logs.detailTitle": "Send Detail",
|
||||
"logs.detailMissing": "Could not load this entry.",
|
||||
"logs.id": "ID",
|
||||
"logs.time": "Time",
|
||||
"logs.route": "Route",
|
||||
"logs.event": "Event",
|
||||
"logs.action": "Action",
|
||||
"logs.actor": "Actor",
|
||||
"logs.deliveryId": "Delivery ID",
|
||||
"logs.platform": "Platform",
|
||||
"logs.status": "HTTP Status",
|
||||
"logs.messageId": "Message ID",
|
||||
"logs.errorCode": "Error Code",
|
||||
"logs.duration": "Duration",
|
||||
"logs.attempts": "Attempts",
|
||||
"logs.ok": "Result",
|
||||
"logs.yes": "OK",
|
||||
"logs.no": "Failed",
|
||||
"logs.error": "Error",
|
||||
"logs.detail": "Extra Info",
|
||||
"logs.filterGroup": "Group",
|
||||
"logs.allGroups": "All groups",
|
||||
"audit.title": "AUDIT LOG",
|
||||
"audit.empty": "No audit entries yet.",
|
||||
"audit.time": "Time",
|
||||
"audit.actor": "Actor",
|
||||
"audit.action": "Action",
|
||||
"audit.target": "Target",
|
||||
"audit.group": "Group",
|
||||
"audit.detail": "Detail",
|
||||
"audit.filterGroup": "Group",
|
||||
"audit.allGroups": "All groups",
|
||||
"audit.refresh": "⟳ Refresh",
|
||||
"toast.routesSaved": "Routes saved",
|
||||
"toast.routeDeleted": "Route deleted",
|
||||
"toast.groupSaved": "Groups saved",
|
||||
"toast.groupDeleted": "Group deleted",
|
||||
"toast.routeIdExists": "Route ID already exists",
|
||||
"toast.groupIdExists": "Group ID already exists",
|
||||
"toast.saveFailed": "Save failed: {msg}",
|
||||
"toast.deleteFailed": "Delete failed: {msg}",
|
||||
"confirm.deleteRoute": 'Delete route "{name}"?',
|
||||
"confirm.deleteGroup": 'Delete group "{name}"?',
|
||||
"confirm.deleteGroupWarn": " {n} route(s) reference it and will lose their group.",
|
||||
};
|
||||
|
||||
const zh: Dict = {
|
||||
"app.tagline": "配置控制台",
|
||||
"app.newRoute": "+ 新建路由",
|
||||
"app.newGroup": "+ 新建分组",
|
||||
"app.signOut": "退出登录",
|
||||
"app.langToggle": "EN",
|
||||
"login.title": "配置控制台",
|
||||
"login.forbidden": "访问被拒绝 —— 该 GitHub 账号不是超级管理员或分组管理员。",
|
||||
"login.prompt": "使用 GitHub 登录以管理路由。",
|
||||
"login.button": "使用 GitHub 登录",
|
||||
"tab.groups": "分组",
|
||||
"tab.logs": "发送日志",
|
||||
"tab.audit": "审计日志",
|
||||
"status.loading": "加载中",
|
||||
"status.connected": "已连接",
|
||||
"kpi.groups": "分组",
|
||||
"kpi.routes": "路由",
|
||||
"role.owner": "拥有者",
|
||||
"role.admin": "管理员",
|
||||
"role.viewer": "只读",
|
||||
"role.badge": "我的角色:{role}",
|
||||
"roles.owner": "拥有者",
|
||||
"roles.admin": "管理员",
|
||||
"roles.viewer": "只读",
|
||||
"members.title": "成员",
|
||||
"members.note": "(角色:owner = 完全控制 · admin = 编辑路由 · viewer = 只读)",
|
||||
"members.empty": "还没有成员 —— 添加第一位 owner 或分享邀请链接。",
|
||||
"members.add": "添加成员",
|
||||
"members.addPlaceholder": "GitHub 登录名",
|
||||
"members.addBtn": "添加",
|
||||
"members.role": "角色",
|
||||
"members.you": "(我)",
|
||||
"members.remove": "移除",
|
||||
"members.invites": "邀请链接",
|
||||
"members.inviteNote": "(一次性 · 7 天内有效)",
|
||||
"members.inviteRole": "邀请为",
|
||||
"members.createInvite": "生成邀请链接",
|
||||
"members.copyLink": "复制",
|
||||
"members.copied": "已复制!",
|
||||
"members.revoke": "撤销",
|
||||
"members.noInvites": "暂无待接受的邀请。",
|
||||
"members.errSelfOwner": "你必须保留本组的 owner 身份。",
|
||||
"members.errOwner": "分组至少需要一位 owner。",
|
||||
"members.errLastOwner": "不能移除最后一位 owner。",
|
||||
"members.inviteOk": "邀请已接受 —— 欢迎!",
|
||||
"members.inviteBad": "该邀请无效或已过期。",
|
||||
"install.ok": "GitHub App 安装已注册 —— 分组已创建并绑定到该安装。",
|
||||
"webhook.title": "Webhook 入口",
|
||||
"webhook.note": "(分组独立的 URL + secret)",
|
||||
"webhook.empty": "未配置。生成 secret 即可启用该分组的独立 webhook 入口。",
|
||||
"webhook.url": "URL",
|
||||
"webhook.secret": "Secret",
|
||||
"webhook.noSecret": "—— 未配置 secret ——",
|
||||
"webhook.secretHidden": "secret 仅在生成/重新生成时显示一次。如需查看请重新生成。",
|
||||
"webhook.copy": "复制",
|
||||
"webhook.copied": "已复制!",
|
||||
"webhook.generate": "生成 secret",
|
||||
"webhook.regenerate": "重新生成",
|
||||
"webhook.disable": "停用",
|
||||
"webhook.usageTitle": "使用方法",
|
||||
"webhook.usageGitHub":
|
||||
"GitHub:仓库/组织 Settings → Webhooks → Add webhook → Payload URL 填上面的 URL,Content type 选 application/json,Secret 填上面的 secret。",
|
||||
"webhook.usageGitea": "Gitea:仓库 Settings → Webhooks → Add Webhook,填写同样的 URL 和 secret。",
|
||||
"webhook.usageCustom":
|
||||
"自定义:用 X-WebHooker-Signature(sha256 HMAC)签名任意 JSON 后 POST。创建一条 event = custom 的路由即可接收:",
|
||||
"groups.admins": "管理员",
|
||||
"groups.owners": "来源",
|
||||
"groups.members": "成员",
|
||||
"groups.any": "任意",
|
||||
"groups.open": "进入 →",
|
||||
"group.back": "← 返回分组",
|
||||
"group.routesIn": "{name} 的路由",
|
||||
"routes.emptyGroup": "该分组还没有路由。",
|
||||
"routes.createFirst": "创建第一个路由",
|
||||
"route.untitled": "(未命名)",
|
||||
"route.noFilters": "无过滤器",
|
||||
"route.channel": "频道",
|
||||
"route.thread": "子区",
|
||||
"route.chat": "群组",
|
||||
"route.topic": "话题",
|
||||
"route.fallback": "兜底",
|
||||
"route.stop": "停止",
|
||||
"route.moveUp": "上移",
|
||||
"route.moveDown": "下移",
|
||||
"filter.event": "事件",
|
||||
"filter.repo": "仓库",
|
||||
"filter.actor": "操作者",
|
||||
"filter.action": "动作",
|
||||
"filter.branch": "分支",
|
||||
"filter.keyword": "关键词",
|
||||
"routeEditor.editTitle": "编辑路由",
|
||||
"routeEditor.newTitle": "新建路由",
|
||||
"routeEditor.close": "关闭",
|
||||
"routeEditor.templates": "模板",
|
||||
"routeEditor.templatesNote": "(点击可预填过滤器)",
|
||||
"templates.push": "推送",
|
||||
"templates.pullRequest": "拉取请求",
|
||||
"templates.issues": "议题",
|
||||
"templates.release": "发布",
|
||||
"templates.workflow": "工作流",
|
||||
"templates.checks": "检查套件",
|
||||
"templates.deployment": "部署",
|
||||
"templates.comments": "评论",
|
||||
"templates.reviews": "评审",
|
||||
"templates.starFork": "星标 / 复刻",
|
||||
"templates.createDelete": "创建 / 删除",
|
||||
"templates.member": "协作者",
|
||||
"templates.commitComment": "提交评论",
|
||||
"templates.customWebhook": "自定义 Webhook",
|
||||
"routeEditor.name": "名称",
|
||||
"routeEditor.namePlaceholder": "我的路由",
|
||||
"routeEditor.id": "ID",
|
||||
"routeEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线",
|
||||
"routeEditor.enabled": "启用路由",
|
||||
"routeEditor.fallback": "兜底路由",
|
||||
"routeEditor.fallbackHint": "仅当没有其它路由匹配该事件时才发送,其过滤器仍会生效",
|
||||
"routeEditor.stop": "匹配后停止",
|
||||
"routeEditor.stopHint": "该路由匹配后,不再评估后续路由",
|
||||
"routeEditor.filters": "过滤器",
|
||||
"routeEditor.filtersNote": "(全部匹配 · AND)",
|
||||
"routeEditor.matchPlaceholder": "匹配值",
|
||||
"routeEditor.not": "取反",
|
||||
"routeEditor.addFilter": "+ 添加过滤器",
|
||||
"routeEditor.discordRoles": "Discord 身份组提醒",
|
||||
"routeEditor.discordRolesNote": "(可选 · 逗号分隔)",
|
||||
"routeEditor.discordRolesPlaceholder": "123456789012345678, 987654321098765432",
|
||||
"routeEditor.discordRolesHint": "当该路由触发时,会在 Discord 目标中提醒(@)这些身份组。",
|
||||
"routeEditor.channel": "频道 ID",
|
||||
"routeEditor.channelPlaceholder": "Discord 频道 ID",
|
||||
"routeEditor.thread": "子区 ID",
|
||||
"routeEditor.threadNote": "(可选)",
|
||||
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
||||
"routeEditor.platform": "平台",
|
||||
"routeEditor.chat": "群组 ID",
|
||||
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
|
||||
"routeEditor.topic": "话题 ID",
|
||||
"routeEditor.topicNote": "(可选)",
|
||||
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
|
||||
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
|
||||
"routeEditor.targets": "目标",
|
||||
"routeEditor.targetsNote": "(一个或多个推送目标)",
|
||||
"routeEditor.addTarget": "+ 添加目标",
|
||||
"routeEditor.errTargets": "至少添加一个目标",
|
||||
"routeEditor.cancel": "取消",
|
||||
"routeEditor.save": "保存路由",
|
||||
"routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值",
|
||||
"routeEditor.errAddFilter": "至少添加一个过滤器",
|
||||
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||
"routeEditor.errName": "名称为必填项",
|
||||
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
||||
"groupEditor.editTitle": "编辑分组",
|
||||
"groupEditor.newTitle": "新建分组",
|
||||
"groupEditor.close": "关闭",
|
||||
"groupEditor.name": "名称",
|
||||
"groupEditor.namePlaceholder": "我的团队",
|
||||
"groupEditor.id": "ID",
|
||||
"groupEditor.idPlaceholder": "my-team",
|
||||
"groupEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线",
|
||||
"groupEditor.renameHint": "修改 id 会同步更新引用该分组的路由、webhook secret 与邀请链接。",
|
||||
"groupEditor.language": "消息语言",
|
||||
"groupEditor.langPlaceholder": "zh",
|
||||
"groupEditor.langHint": "en 或 zh;可通过 KV i18n:<lang> 自定义",
|
||||
"groupEditor.emojiNote": "(默认开启)",
|
||||
"groupEditor.emojiLabel": "消息中显示表情符号",
|
||||
"groupEditor.admins": "分组管理员",
|
||||
"groupEditor.adminsNote": "(GitHub 登录名或用户 ID,逗号分隔)",
|
||||
"groupEditor.adminsPlaceholder": "octocat, 12345",
|
||||
"groupEditor.adminsHint": "这些用户可以查看日志并编辑本分组的路由。",
|
||||
"groupEditor.owners": "来源限定",
|
||||
"groupEditor.ownersNote": "(GitHub 组织 / 用户登录名,逗号分隔)",
|
||||
"groupEditor.ownersSuperOnly": "(仅超级管理员可修改)",
|
||||
"groupEditor.ownersPlaceholder": "my-org, some-user",
|
||||
"groupEditor.ownersHint":
|
||||
"只有来自这些组织/用户的 webhook 事件才会进入本分组的路由。留空表示不限制。",
|
||||
"groupEditor.membersNote": "成员",
|
||||
"groupEditor.membersHint": "(owner / admin / viewer)",
|
||||
"groupEditor.membersGoPanel": "请在分组页面管理成员与邀请链接。",
|
||||
"groupEditor.providers": "来源平台",
|
||||
"groupEditor.providersNote": "(都不勾选 = 全部来源)",
|
||||
"groupEditor.providersHint":
|
||||
"只有来自所勾选 forge(GitHub / Gitea)的 webhook 事件才会进入本分组的路由。",
|
||||
"groupEditor.installationId": "GitHub App 安装 ID",
|
||||
"groupEditor.installationIdNote": "(可选)",
|
||||
"groupEditor.installationIdPlaceholder": "如 12345678",
|
||||
"groupEditor.installationIdHint":
|
||||
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
|
||||
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
|
||||
"groupEditor.emoji": "消息中显示表情符号",
|
||||
"groupEditor.logTarget": "Webhook 日志频道",
|
||||
"groupEditor.logTargetNote": "(可选)",
|
||||
"groupEditor.logDisabled": "— 未启用 —",
|
||||
"groupEditor.logTargetHint":
|
||||
"一个 Discord 频道/子区或 Telegram 群组/话题,本分组路由分发(dispatch)的每个 webhook 都会向其发送摘要。",
|
||||
"groupEditor.errLogChannel": "选择 Discord 时必须填写日志频道 ID",
|
||||
"groupEditor.errLogChat": "选择 Telegram 时必须填写日志群组 ID",
|
||||
"groupEditor.cancel": "取消",
|
||||
"groupEditor.save": "保存分组",
|
||||
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||
"groupEditor.errName": "名称为必填项",
|
||||
"logs.lastSends": "最近 {n} 条发送",
|
||||
"logs.refresh": "⟳ 刷新",
|
||||
"logs.empty": "还没有发送记录 —— 触发一次 webhook 即可查看结果。",
|
||||
"logs.repo": "仓库",
|
||||
"logs.target": "目标",
|
||||
"logs.detailTitle": "发送详情",
|
||||
"logs.detailMissing": "无法加载该条记录。",
|
||||
"logs.id": "ID",
|
||||
"logs.time": "时间",
|
||||
"logs.route": "路由",
|
||||
"logs.event": "事件",
|
||||
"logs.action": "动作",
|
||||
"logs.actor": "操作者",
|
||||
"logs.deliveryId": "Delivery ID",
|
||||
"logs.platform": "平台",
|
||||
"logs.status": "HTTP 状态码",
|
||||
"logs.messageId": "消息 ID",
|
||||
"logs.errorCode": "错误码",
|
||||
"logs.duration": "耗时",
|
||||
"logs.attempts": "尝试次数",
|
||||
"logs.ok": "结果",
|
||||
"logs.yes": "成功",
|
||||
"logs.no": "失败",
|
||||
"logs.error": "错误信息",
|
||||
"logs.detail": "附加信息",
|
||||
"logs.filterGroup": "分组",
|
||||
"logs.allGroups": "全部分组",
|
||||
"audit.title": "审计日志",
|
||||
"audit.empty": "暂无审计记录。",
|
||||
"audit.time": "时间",
|
||||
"audit.actor": "操作者",
|
||||
"audit.action": "动作",
|
||||
"audit.target": "对象",
|
||||
"audit.group": "分组",
|
||||
"audit.detail": "详情",
|
||||
"audit.filterGroup": "分组",
|
||||
"audit.allGroups": "全部分组",
|
||||
"audit.refresh": "⟳ 刷新",
|
||||
"toast.routesSaved": "路由已保存",
|
||||
"toast.routeDeleted": "路由已删除",
|
||||
"toast.groupSaved": "分组已保存",
|
||||
"toast.groupDeleted": "分组已删除",
|
||||
"toast.routeIdExists": "路由 ID 已存在",
|
||||
"toast.groupIdExists": "分组 ID 已存在",
|
||||
"toast.saveFailed": "保存失败:{msg}",
|
||||
"toast.deleteFailed": "删除失败:{msg}",
|
||||
"confirm.deleteRoute": "确定删除路由“{name}”吗?",
|
||||
"confirm.deleteGroup": "确定删除分组“{name}”吗?",
|
||||
"confirm.deleteGroupWarn": " 有 {n} 条路由引用它,将失去所属分组。",
|
||||
};
|
||||
|
||||
const messages: Record<Locale, Dict> = { en, zh };
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof window === "undefined") return "zh";
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
const nav = (window.navigator.language || "").toLowerCase();
|
||||
return nav.startsWith("zh") ? "zh" : "en";
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const locale = useState<Locale>("wh-locale", detectLocale);
|
||||
|
||||
function setLocale(next: Locale): void {
|
||||
locale.value = next;
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
setLocale(locale.value === "zh" ? "en" : "zh");
|
||||
}
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
const dict = messages[locale.value] ?? en;
|
||||
let str = dict[key] ?? en[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
str = str.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
return { locale, setLocale, toggle, t };
|
||||
}
|
||||
71
app/composables/useInvites.ts
Normal file
71
app/composables/useInvites.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { GroupInvite } from "~/types";
|
||||
|
||||
export function useInvitesApi() {
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function create(groupId: string, role: "admin" | "viewer"): Promise<string> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/invites`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { url?: string };
|
||||
return data.url ?? "";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function list(groupId: string): Promise<GroupInvite[]> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/invites`, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { invites?: GroupInvite[] };
|
||||
return data.invites ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
return [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch(`/admin/api/invites/${encodeURIComponent(token)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, create, list, revoke };
|
||||
}
|
||||
49
app/composables/useLogs.ts
Normal file
49
app/composables/useLogs.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { SendRecord } from "~/types";
|
||||
|
||||
export function useSendLogs() {
|
||||
const logs = ref<SendRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const needLogin = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load(limit = 50, groupId?: string): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
needLogin.value = false;
|
||||
try {
|
||||
let url = `/admin/api/logs?limit=${limit}`;
|
||||
if (groupId) url += `&groupId=${encodeURIComponent(groupId)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { logs?: SendRecord[] };
|
||||
logs.value = data.logs ?? [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadById(id: number): Promise<SendRecord | null> {
|
||||
const res = await fetch(`/admin/api/logs/${id}`, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
needLogin.value = true;
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { log?: SendRecord };
|
||||
return data.log ?? null;
|
||||
}
|
||||
|
||||
return { logs, loading, needLogin, error, load, loadById };
|
||||
}
|
||||
23
app/composables/useToasts.ts
Normal file
23
app/composables/useToasts.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface Toast {
|
||||
id: number;
|
||||
msg: string;
|
||||
kind: "ok" | "bad";
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
const toasts = useState<Toast[]>("app-toasts", () => []);
|
||||
|
||||
function push(msg: string, kind: "ok" | "bad" = "ok"): void {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
toasts.value.push({ id, msg, kind });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
function dismiss(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return { toasts, push, dismiss };
|
||||
}
|
||||
50
app/composables/useWebhook.ts
Normal file
50
app/composables/useWebhook.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface GroupWebhookInfo {
|
||||
url: string;
|
||||
hasSecret: boolean;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export function useWebhookApi() {
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch(path, {
|
||||
headers: { accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function info(groupId: string): Promise<GroupWebhookInfo> {
|
||||
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`);
|
||||
}
|
||||
|
||||
function regenerate(groupId: string): Promise<GroupWebhookInfo> {
|
||||
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook/regenerate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
function disable(groupId: string): Promise<{ ok: boolean }> {
|
||||
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
return { loading, error, info, regenerate, disable };
|
||||
}
|
||||
146
app/pages/index.vue
Normal file
146
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<div class="relative z-[1] mx-auto flex min-h-screen max-w-[720px] flex-col px-5 pb-16 pt-[72px]">
|
||||
<div class="mb-10 text-center">
|
||||
<div
|
||||
class="mb-5 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-accent text-xl font-extrabold tracking-tight text-white shadow-[0_8px_24px_-8px_var(--accent)]"
|
||||
>
|
||||
WH
|
||||
</div>
|
||||
<h1 class="mb-2.5 text-[34px] font-extrabold tracking-[-0.03em]">
|
||||
Web<span class="text-accent">Hooker</span>
|
||||
</h1>
|
||||
<p class="m-0 text-[15px] leading-relaxed text-muted">
|
||||
{{
|
||||
lang === "zh"
|
||||
? "将 GitHub webhook 事件转发到 Discord 频道,并支持在 Discord 中以你本人身份操作 GitHub。"
|
||||
: "Forward GitHub webhook events to Discord, and act on GitHub from Discord as yourself."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<nav class="grid gap-3">
|
||||
<a
|
||||
v-for="it in items"
|
||||
:key="it.label"
|
||||
class="flex items-center gap-4 rounded-[14px] border border-border bg-surface px-5 py-[18px] text-text no-underline shadow-card transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-card-hover"
|
||||
:class="it.primary ? 'border-accent bg-accent shadow-none hover:border-accent hover:shadow-accent-lg' : ''"
|
||||
:href="it.href"
|
||||
:target="it.external ? '_blank' : undefined"
|
||||
:rel="it.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 flex-none items-center justify-center rounded-[11px] text-accent"
|
||||
:class="it.primary ? 'bg-white/15 text-white' : 'bg-surface-2'"
|
||||
v-html="icon(it.icon)"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-[15px] font-bold" :class="it.primary ? 'text-white' : ''">{{
|
||||
it.label
|
||||
}}</span>
|
||||
<span class="text-[13px]" :class="it.primary ? 'text-white/80' : 'text-muted'">{{
|
||||
it.desc
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="flex-none text-lg" :class="it.primary ? 'text-white' : 'text-faint'">{{
|
||||
it.external ? "↗" : "→"
|
||||
}}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<footer class="mt-auto flex flex-wrap items-center justify-between gap-3 pt-10">
|
||||
<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:border-border-strong hover:text-text"
|
||||
:to="`/?lang=${altLang}`"
|
||||
>{{ lang === "zh" ? "English" : "中文" }}</NuxtLink
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const DEFAULT_REPO = "https://github.com/ReCloudStudio/WebHooker";
|
||||
const DEFAULT_DOCS = "https://webhooker.docs.worldexecute.me";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const lang = computed(() => (route.query.lang === "en" ? "en" : "zh"));
|
||||
const altLang = computed(() => (lang.value === "zh" ? "en" : "zh"));
|
||||
const repo = computed(() => (config.public.repoUrl as string) || DEFAULT_REPO);
|
||||
const docsBase = computed(() => ((config.public.docsUrl as string) || DEFAULT_DOCS).replace(/\/+$/, ""));
|
||||
|
||||
const t = (zh: string, en: string): string => (lang.value === "zh" ? zh : en);
|
||||
|
||||
const items = computed(() => {
|
||||
const docs = lang.value === "zh" ? `${docsBase.value}/zh` : `${docsBase.value}/`;
|
||||
const q = (p: string): string => `${p}?lang=${lang.value}`;
|
||||
return [
|
||||
{
|
||||
href: docs,
|
||||
label: t("文档", "Documentation"),
|
||||
desc: t("部署、配置与事件参考", "Deployment, configuration & event reference"),
|
||||
icon: "docs",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: repo.value,
|
||||
label: t("GitHub 仓库", "GitHub Repository"),
|
||||
desc: t("源代码、问题与发布", "Source code, issues & releases"),
|
||||
icon: "github",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: q("/terms"),
|
||||
label: t("服务条款", "Terms of Service"),
|
||||
desc: t("使用本服务的条款", "The terms for using this service"),
|
||||
icon: "terms",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: q("/privacy"),
|
||||
label: t("隐私政策", "Privacy Policy"),
|
||||
desc: t("我们如何处理你的数据", "How we handle your data"),
|
||||
icon: "privacy",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: "/admin/login",
|
||||
label: t("登录控制台", "Sign in to Console"),
|
||||
desc: t("管理路由与分组", "Manage routes and groups"),
|
||||
icon: "login",
|
||||
external: false,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const ICON_PATHS: Record<string, string> = {
|
||||
docs: '<path d="M4 4a2 2 0 0 1 2-2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z"/><path d="M13 2v5h5"/><path d="M8 12h8M8 16h6"/>',
|
||||
github:
|
||||
'<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0 0 12 2z"/>',
|
||||
terms: '<path d="M9 12h6M9 16h6M9 8h2"/><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"/>',
|
||||
privacy:
|
||||
'<path d="M12 2l7 3v6c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V5l7-3z"/><path d="M9 12l2 2 4-4"/>',
|
||||
login:
|
||||
'<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/>',
|
||||
};
|
||||
|
||||
function icon(name: string): string {
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" width="22" height="22">${ICON_PATHS[name] ?? ""}</svg>`;
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: "WebHooker",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
lang.value === "zh"
|
||||
? "GitHub webhook 转发到 Discord"
|
||||
: "GitHub webhooks forwarded to Discord",
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
17
app/pages/privacy.vue
Normal file
17
app/pages/privacy.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<LegalLayout
|
||||
active="privacy"
|
||||
:title="lang === 'zh' ? '隐私政策' : 'Privacy Policy'"
|
||||
:body="privacyBody(lang, contact)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, legalContact, privacyBody } from "~/utils/legal";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const lang = computed(() => pickLang(String(route.query.lang ?? "")));
|
||||
const contact = computed(() => legalContact(lang.value, String(config.public.legalContact ?? "")));
|
||||
</script>
|
||||
17
app/pages/terms.vue
Normal file
17
app/pages/terms.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<LegalLayout
|
||||
active="terms"
|
||||
:title="lang === 'zh' ? '服务条款' : 'Terms of Service'"
|
||||
:body="termsBody(lang, contact)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { pickLang, legalContact, termsBody } from "~/utils/legal";
|
||||
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const lang = computed(() => pickLang(String(route.query.lang ?? "")));
|
||||
const contact = computed(() => legalContact(lang.value, String(config.public.legalContact ?? "")));
|
||||
</script>
|
||||
184
app/types.ts
Normal file
184
app/types.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
export interface Filter {
|
||||
type: "event" | "repo" | "actor" | "action" | "branch" | "keyword";
|
||||
match: string | string[];
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteTarget {
|
||||
platform?: "discord" | "telegram";
|
||||
channelId?: string;
|
||||
threadId?: string;
|
||||
chatId?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
filters: Filter[];
|
||||
targets: RouteTarget[];
|
||||
groupId?: string;
|
||||
fallback?: boolean;
|
||||
stop?: boolean;
|
||||
discordRoleIds?: string[];
|
||||
}
|
||||
|
||||
export type GroupRole = "owner" | "admin" | "viewer";
|
||||
|
||||
export interface GroupMember {
|
||||
login: string;
|
||||
role: GroupRole;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
adminIds: string[];
|
||||
members?: GroupMember[];
|
||||
owners?: string[];
|
||||
providers?: ("github" | "gitea" | "gitlab")[];
|
||||
installationId?: number;
|
||||
emoji?: boolean;
|
||||
lang?: string;
|
||||
logTarget?: RouteTarget;
|
||||
}
|
||||
|
||||
export interface GroupInvite {
|
||||
token: string;
|
||||
groupId: string;
|
||||
role: "admin" | "viewer";
|
||||
expiresAt: number;
|
||||
createdBy: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id?: number;
|
||||
ts: number;
|
||||
actorId?: string;
|
||||
actorLogin?: string;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
groupId?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export interface RouteTemplate {
|
||||
id: string;
|
||||
nameKey: string;
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
export const ROUTE_TEMPLATES: RouteTemplate[] = [
|
||||
{
|
||||
id: "push-events",
|
||||
nameKey: "templates.push",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
},
|
||||
{
|
||||
id: "pull-requests",
|
||||
nameKey: "templates.pullRequest",
|
||||
filters: [
|
||||
{ type: "event", match: "pull_request" },
|
||||
{ type: "action", match: ["opened", "synchronize", "reopened", "closed"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "issues",
|
||||
nameKey: "templates.issues",
|
||||
filters: [
|
||||
{ type: "event", match: "issues" },
|
||||
{ type: "action", match: ["opened", "edited", "closed", "reopened"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "releases",
|
||||
nameKey: "templates.release",
|
||||
filters: [
|
||||
{ type: "event", match: "release" },
|
||||
{ type: "action", match: ["published"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
nameKey: "templates.workflow",
|
||||
filters: [{ type: "event", match: "workflow_run" }],
|
||||
},
|
||||
{
|
||||
id: "checks",
|
||||
nameKey: "templates.checks",
|
||||
filters: [{ type: "event", match: ["check_suite"] }],
|
||||
},
|
||||
{
|
||||
id: "deployments",
|
||||
nameKey: "templates.deployment",
|
||||
filters: [{ type: "event", match: "deployment" }],
|
||||
},
|
||||
{
|
||||
id: "comments",
|
||||
nameKey: "templates.comments",
|
||||
filters: [{ type: "event", match: ["issue_comment", "pull_request_review_comment"] }],
|
||||
},
|
||||
{
|
||||
id: "reviews",
|
||||
nameKey: "templates.reviews",
|
||||
filters: [{ type: "event", match: "pull_request_review" }],
|
||||
},
|
||||
{
|
||||
id: "star-fork",
|
||||
nameKey: "templates.starFork",
|
||||
filters: [{ type: "event", match: ["star", "fork"] }],
|
||||
},
|
||||
{
|
||||
id: "create-delete",
|
||||
nameKey: "templates.createDelete",
|
||||
filters: [{ type: "event", match: ["create", "delete"] }],
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
nameKey: "templates.member",
|
||||
filters: [{ type: "event", match: "member" }],
|
||||
},
|
||||
{
|
||||
id: "commit-comments",
|
||||
nameKey: "templates.commitComment",
|
||||
filters: [{ type: "event", match: "commit_comment" }],
|
||||
},
|
||||
{
|
||||
id: "custom-webhook",
|
||||
nameKey: "templates.customWebhook",
|
||||
filters: [{ type: "event", match: "custom" }],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
||||
|
||||
export function fmtMatch(match: string | string[]): string {
|
||||
if (Array.isArray(match)) return match.join(", ");
|
||||
return String(match ?? "");
|
||||
}
|
||||
|
||||
export interface SendRecord {
|
||||
id?: number;
|
||||
ts: number;
|
||||
routeId: string;
|
||||
groupId?: string;
|
||||
event: string;
|
||||
repo?: string;
|
||||
target: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
status?: number;
|
||||
messageId?: string;
|
||||
deliveryId?: string;
|
||||
platform?: string;
|
||||
actor?: string;
|
||||
action?: string;
|
||||
durationMs?: number;
|
||||
errorCode?: string;
|
||||
attempts?: number;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
145
app/utils/legal.ts
Normal file
145
app/utils/legal.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
export type Lang = "zh" | "en";
|
||||
|
||||
const CONTACT_FALLBACK = "the repository maintainer (open an issue on the project repository)";
|
||||
const CONTACT_FALLBACK_ZH = "项目维护者(在项目仓库提交 issue)";
|
||||
export const LEGAL_UPDATED = "2026-08-01";
|
||||
|
||||
export function pickLang(raw: string | undefined): Lang {
|
||||
return raw === "en" ? "en" : "zh";
|
||||
}
|
||||
|
||||
export function legalContact(lang: Lang, envContact: string): string {
|
||||
return envContact || (lang === "zh" ? CONTACT_FALLBACK_ZH : CONTACT_FALLBACK);
|
||||
}
|
||||
|
||||
export function termsBody(lang: Lang, contact: string): string {
|
||||
if (lang === "en") {
|
||||
return `
|
||||
<p>These Terms of Service ("Terms") govern your use of the WebHooker Discord application and bot ("the Service"), which forwards GitHub webhook events to Discord channels and lets you comment on GitHub from Discord using your own linked GitHub account.</p>
|
||||
<h2>1. Acceptance</h2>
|
||||
<p>By adding the bot to a Discord server, using its slash commands, or linking your GitHub account, you agree to these Terms. If you do not agree, do not use the Service.</p>
|
||||
<h2>2. The Service</h2>
|
||||
<ul>
|
||||
<li>Delivers GitHub repository notifications to configured Discord channels.</li>
|
||||
<li>Provides <code>/gh</code> commands to link a GitHub account and to create, edit, or delete GitHub issue/PR comments as that linked account.</li>
|
||||
<li>All GitHub write actions are performed with your own OAuth authorization and are subject to your GitHub permissions and to GitHub's Terms of Service.</li>
|
||||
</ul>
|
||||
<h2>3. Acceptable Use</h2>
|
||||
<ul>
|
||||
<li>Do not use the Service to post spam, harassment, or unlawful content.</li>
|
||||
<li>Do not attempt to disrupt, overload, reverse-engineer, or gain unauthorized access to the Service.</li>
|
||||
<li>You are responsible for all activity performed through your linked account.</li>
|
||||
</ul>
|
||||
<h2>4. Account Linking & Revocation</h2>
|
||||
<p>Linking is optional and initiated by you via <code>/gh login</code>. You may unlink at any time with <code>/gh logout</code>, or revoke access from your GitHub account settings under authorized OAuth applications.</p>
|
||||
<h2>5. Availability & Warranty</h2>
|
||||
<p>The Service is provided "as is" and "as available", without warranties of any kind. It may be modified, suspended, or discontinued at any time without notice.</p>
|
||||
<h2>6. Limitation of Liability</h2>
|
||||
<p>To the maximum extent permitted by law, the operator shall not be liable for any indirect, incidental, or consequential damages arising from your use of the Service.</p>
|
||||
<h2>7. Changes</h2>
|
||||
<p>These Terms may be updated. Continued use after changes constitutes acceptance of the revised Terms.</p>
|
||||
<h2>8. Contact</h2>
|
||||
<p>Questions about these Terms can be directed to ${contact}.</p>`;
|
||||
}
|
||||
return `
|
||||
<p>本《服务条款》(下称"条款")适用于你对 WebHooker Discord 应用与机器人(下称"本服务")的使用。本服务用于将 GitHub 的 webhook 事件转发到 Discord 频道,并允许你在 Discord 中以你本人绑定的 GitHub 账号对 GitHub 进行评论。</p>
|
||||
<h2>1. 接受条款</h2>
|
||||
<p>当你将机器人添加到 Discord 服务器、使用其斜杠命令,或绑定你的 GitHub 账号时,即表示你同意本条款。如不同意,请勿使用本服务。</p>
|
||||
<h2>2. 服务内容</h2>
|
||||
<ul>
|
||||
<li>将 GitHub 仓库通知投递到已配置的 Discord 频道。</li>
|
||||
<li>提供 <code>/gh</code> 命令用于绑定 GitHub 账号,并以该绑定账号创建、编辑或删除 GitHub issue/PR 评论。</li>
|
||||
<li>所有对 GitHub 的写入操作均使用你本人的 OAuth 授权执行,受你的 GitHub 权限以及 GitHub 服务条款约束。</li>
|
||||
</ul>
|
||||
<h2>3. 可接受使用</h2>
|
||||
<ul>
|
||||
<li>不得利用本服务发布垃圾信息、骚扰或违法内容。</li>
|
||||
<li>不得试图干扰、超载、逆向工程或未经授权访问本服务。</li>
|
||||
<li>你对通过绑定账号执行的一切操作负责。</li>
|
||||
</ul>
|
||||
<h2>4. 账号绑定与解除</h2>
|
||||
<p>绑定为可选,由你通过 <code>/gh login</code> 主动发起。你可随时使用 <code>/gh logout</code> 解除绑定,或在 GitHub 账号设置的已授权 OAuth 应用中撤销授权。</p>
|
||||
<h2>5. 可用性与免责声明</h2>
|
||||
<p>本服务按"现状"和"现有可用"基础提供,不作任何形式的保证。服务可能随时被修改、暂停或终止,恕不另行通知。</p>
|
||||
<h2>6. 责任限制</h2>
|
||||
<p>在法律允许的最大范围内,运营者对因使用本服务而产生的任何间接、附带或后果性损害不承担责任。</p>
|
||||
<h2>7. 条款变更</h2>
|
||||
<p>本条款可能会更新。变更后继续使用即视为接受修订后的条款。</p>
|
||||
<h2>8. 联系方式</h2>
|
||||
<p>关于本条款的问题可联系${contact}。</p>`;
|
||||
}
|
||||
|
||||
export function privacyBody(lang: Lang, contact: string): string {
|
||||
if (lang === "en") {
|
||||
return `
|
||||
<p>This Privacy Policy explains what data the WebHooker Discord application ("the Service") processes, why, and how it is stored.</p>
|
||||
<h2>1. Data We Process</h2>
|
||||
<ul>
|
||||
<li><b>Discord identifiers</b>: your Discord user ID, only when you run <code>/gh login</code>, to link you to a GitHub account.</li>
|
||||
<li><b>GitHub OAuth tokens</b>: an access token (and refresh token, if provided) issued when you authorize the app, used to act on GitHub on your behalf.</li>
|
||||
<li><b>GitHub account info</b>: your GitHub user ID and login name, returned during authorization.</li>
|
||||
<li><b>Webhook payloads</b>: GitHub event data is received and forwarded to Discord. It is processed in transit and is not stored beyond short-lived delivery de-duplication keys.</li>
|
||||
<li><b>Operational logs</b>: minimal send/delivery records kept temporarily for troubleshooting.</li>
|
||||
</ul>
|
||||
<h2>2. How Data Is Stored</h2>
|
||||
<ul>
|
||||
<li>Data is stored in Cloudflare Workers KV within the operator's Cloudflare account.</li>
|
||||
<li>Delivery de-duplication keys expire automatically (about 5 minutes).</li>
|
||||
<li>Send logs expire automatically (about 1 hour).</li>
|
||||
<li>OAuth tokens persist until you unlink or the token expires/is revoked.</li>
|
||||
</ul>
|
||||
<h2>3. How Data Is Used</h2>
|
||||
<p>Solely to operate the Service: forwarding notifications and performing GitHub actions (comment create/edit/delete) that you explicitly request. We do not sell your data or use it for advertising.</p>
|
||||
<h2>4. Sharing</h2>
|
||||
<p>Data is shared only with the platforms required to deliver the Service — GitHub and Discord — through their official APIs, and with Cloudflare as the hosting/storage provider. No other third-party sharing occurs.</p>
|
||||
<h2>5. Your Choices</h2>
|
||||
<ul>
|
||||
<li>Run <code>/gh logout</code> to delete the Discord-to-GitHub link.</li>
|
||||
<li>Revoke the OAuth authorization from your GitHub settings (Applications → Authorized OAuth Apps) to invalidate stored tokens.</li>
|
||||
<li>Remove the bot from a server to stop notification delivery.</li>
|
||||
</ul>
|
||||
<h2>6. Data Retention</h2>
|
||||
<p>We retain data only as long as needed for the Service. Transient data expires automatically as described above; account links and tokens are removed when you unlink or revoke access.</p>
|
||||
<h2>7. Children</h2>
|
||||
<p>The Service is not directed to individuals under the age required by Discord's Terms of Service, and we do not knowingly collect their data.</p>
|
||||
<h2>8. Changes</h2>
|
||||
<p>This policy may be updated. Material changes will be reflected by the "Last updated" date above.</p>
|
||||
<h2>9. Contact</h2>
|
||||
<p>For privacy questions or data-removal requests, contact ${contact}.</p>`;
|
||||
}
|
||||
return `
|
||||
<p>本《隐私政策》说明 WebHooker Discord 应用(下称"本服务")处理哪些数据、为何处理以及如何存储。</p>
|
||||
<h2>1. 我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><b>Discord 标识</b>:仅在你执行 <code>/gh login</code> 时收集你的 Discord 用户 ID,用于将你与 GitHub 账号绑定。</li>
|
||||
<li><b>GitHub OAuth 令牌</b>:在你授权应用时签发的访问令牌(如提供,还包括刷新令牌),用于代表你在 GitHub 上执行操作。</li>
|
||||
<li><b>GitHub 账号信息</b>:授权过程中返回的 GitHub 用户 ID 和登录名。</li>
|
||||
<li><b>Webhook 负载</b>:接收 GitHub 事件数据并转发到 Discord,仅在传输过程中处理,除短期的投递去重键外不作留存。</li>
|
||||
<li><b>运行日志</b>:为便于排障而临时保留的极少量发送/投递记录。</li>
|
||||
</ul>
|
||||
<h2>2. 数据如何存储</h2>
|
||||
<ul>
|
||||
<li>数据存储于运营者 Cloudflare 账号下的 Cloudflare Workers KV 中。</li>
|
||||
<li>投递去重键自动过期(约 5 分钟)。</li>
|
||||
<li>发送日志自动过期(约 1 小时)。</li>
|
||||
<li>OAuth 令牌在你解除绑定或令牌过期/被撤销前持续保留。</li>
|
||||
</ul>
|
||||
<h2>3. 数据如何使用</h2>
|
||||
<p>仅用于运行本服务:转发通知,以及执行你明确请求的 GitHub 操作(评论的创建/编辑/删除)。我们不出售你的数据,也不用于广告。</p>
|
||||
<h2>4. 数据共享</h2>
|
||||
<p>数据仅通过官方 API 与提供本服务所必需的平台共享——即 GitHub 与 Discord——以及作为托管/存储提供方的 Cloudflare。不存在其他第三方共享。</p>
|
||||
<h2>5. 你的选择</h2>
|
||||
<ul>
|
||||
<li>执行 <code>/gh logout</code> 删除 Discord 与 GitHub 的绑定。</li>
|
||||
<li>在 GitHub 设置(Applications → Authorized OAuth Apps)中撤销 OAuth 授权,使已存令牌失效。</li>
|
||||
<li>将机器人移出服务器以停止通知投递。</li>
|
||||
</ul>
|
||||
<h2>6. 数据留存</h2>
|
||||
<p>我们仅在提供本服务所需的期间内保留数据。临时数据按上文自动过期;账号绑定与令牌在你解除绑定或撤销授权时被删除。</p>
|
||||
<h2>7. 未成年人</h2>
|
||||
<p>本服务不面向低于 Discord 服务条款所要求年龄的个人,我们也不会有意收集其数据。</p>
|
||||
<h2>8. 政策变更</h2>
|
||||
<p>本政策可能会更新。重大变更将通过上方"最后更新"日期体现。</p>
|
||||
<h2>9. 联系方式</h2>
|
||||
<p>如有隐私问题或数据删除请求,请联系${contact}。</p>`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue