WebHooker/app/components/GroupEditor.vue
2026-08-26 16:39:02 +00:00

396 lines
14 KiB
Vue

<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="overlay" @click.self="close"></div>
</Transition>
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<div class="editor-heading">
<span class="editor-eyebrow">{{ t("groupEditor.eyebrow") }}</span>
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
</div>
<button class="icon-btn" :title="t('groupEditor.close')" @click="close"></button>
</div>
<form class="editor-body" @submit.prevent="save">
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionBasic") }}</h3>
<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>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionPreferences") }}</h3>
<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.forgeSources") }}
<span class="lbl-note">{{ t("groupEditor.forgeSourcesNote") }}</span></label
>
<div v-for="(s, i) in form.forgeSources" :key="i" class="forge-source-row">
<input
v-model="s.host"
type="text"
class="input f-host"
:placeholder="t('groupEditor.forgeSourcesHost')"
/>
<select v-model="s.type" class="select forge-type">
<option value="github">GitHub</option>
<option value="gitea">Gitea</option>
</select>
<button
type="button"
class="icon-btn danger"
@click="form.forgeSources.splice(i, 1)"
>
</button>
<input
v-model="s.name"
type="text"
class="input f-name"
:placeholder="t('groupEditor.forgeSourcesName')"
/>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addForgeSource">
{{ t("groupEditor.forgeSourcesAdd") }}
</button>
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionAccess") }}</h3>
<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>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionProviders") }}</h3>
<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>
</section>
<section class="editor-section">
<h3 class="editor-section-title">{{ t("groupEditor.sectionLog") }}</h3>
<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>
<option value="feishu">{{ t("groupEditor.logFeishu") }}</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' || form.logPlatform === 'feishu'"
>
<input
v-model="form.logChatId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-if="form.logPlatform === 'telegram'"
v-model="form.logTopicId"
type="text"
class="input mt-2"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
</div>
</section>
<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 { ForgeSource, 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,
forgeSources: [] as ForgeSource[],
lang: "",
logPlatform: "" as "" | "discord" | "telegram" | "feishu",
logChannelId: "",
logThreadId: "",
logChatId: "",
logTopicId: "",
});
function addForgeSource(): void {
form.forgeSources.push({ host: "", type: "github" });
}
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.forgeSources = (g?.forgeSources ?? []).map((s) => ({ ...s }));
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 }
| { platform: "feishu"; chatId: 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 };
} else if (form.logPlatform === "feishu") {
const chatId = form.logChatId.trim();
if (!chatId) {
formError.value = t("groupEditor.errLogFeishuChat");
return;
}
logTarget = { platform: "feishu", chatId };
}
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;
}
}
for (const s of form.forgeSources) {
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(s.host.trim())) {
formError.value = t("groupEditor.errForgeSourceHost");
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,
forgeSources: form.forgeSources.length
? form.forgeSources.map((s) => ({
host: s.host.trim(),
type: s.type,
...(s.name?.trim() ? { name: s.name.trim() } : {}),
}))
: undefined,
lang: form.lang.trim() || undefined,
logTarget,
});
}
</script>