mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(admin): add zh/en i18n to the config console
Introduce a lightweight useI18n composable (zh default, auto-detect via navigator.language, manual toggle persisted to localStorage) with full zh/en dictionaries, and wire translation keys through the console UI.
This commit is contained in:
parent
d3c1349d00
commit
9e0f21c538
3 changed files with 280 additions and 25 deletions
|
|
@ -6,39 +6,44 @@
|
|||
<Transition name="slide">
|
||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||
<div class="editor-head">
|
||||
<h2>{{ isEdit ? "Edit group" : "New group" }}</h2>
|
||||
<button class="icon-btn" title="Close" @click="close">✕</button>
|
||||
<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>Name</label>
|
||||
<input v-model="form.name" type="text" placeholder="My Team" required />
|
||||
<label>{{ t("groupEditor.name") }}</label>
|
||||
<input v-model="form.name" type="text" :placeholder="t('groupEditor.namePlaceholder')" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ID</label>
|
||||
<input v-model="form.id" type="text" placeholder="my-team" :disabled="isEdit" required />
|
||||
<div class="hint">Unique, use a-z / 0-9 / dashes</div>
|
||||
<label>{{ t("groupEditor.id") }}</label>
|
||||
<input
|
||||
v-model="form.id"
|
||||
type="text"
|
||||
:placeholder="t('groupEditor.idPlaceholder')"
|
||||
:disabled="isEdit"
|
||||
required
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.idHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Group admins <span class="lbl-note">(GitHub login or user id, comma-separated)</span></label>
|
||||
<input v-model="form.adminIds" type="text" placeholder="octocat, 12345" />
|
||||
<div class="hint">These users can view logs and edit this group's routes.</div>
|
||||
<label>{{ t("groupEditor.admins") }} <span class="lbl-note">{{ t("groupEditor.adminsNote") }}</span></label>
|
||||
<input v-model="form.adminIds" type="text" :placeholder="t('groupEditor.adminsPlaceholder')" />
|
||||
<div class="hint">{{ t("groupEditor.adminsHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Owner scope <span class="lbl-note">(GitHub org / user logins, comma-separated)</span></label>
|
||||
<input v-model="form.owners" type="text" placeholder="my-org, some-user" />
|
||||
<div class="hint">
|
||||
Only webhook events from these orgs/users enter this group's routes. Leave empty for no
|
||||
restriction.
|
||||
</div>
|
||||
<label>{{ t("groupEditor.owners") }} <span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label>
|
||||
<input v-model="form.owners" type="text" :placeholder="t('groupEditor.ownersPlaceholder')" />
|
||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||
</div>
|
||||
<div class="err">{{ formError }}</div>
|
||||
</form>
|
||||
<div class="editor-foot">
|
||||
<button class="btn btn-ghost" type="button" @click="close">Cancel</button>
|
||||
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">Save group</button>
|
||||
<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>
|
||||
|
|
@ -49,6 +54,8 @@
|
|||
import { reactive, watch } from "vue";
|
||||
import type { Group } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
|
|
@ -94,11 +101,11 @@ function save(): void {
|
|||
const id = form.id.trim();
|
||||
const name = form.name.trim();
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
formError.value = "ID must be a-z / 0-9 / dashes";
|
||||
formError.value = t("groupEditor.errIdFormat");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
formError.value = "Name is required";
|
||||
formError.value = t("groupEditor.errName");
|
||||
return;
|
||||
}
|
||||
const owners = splitList(form.owners);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="log-toolbar">
|
||||
<span class="kpi-label">LAST {{ logs.length }} SENDS</span>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">⟳ Refresh</button>
|
||||
<span class="kpi-label">{{ t("logs.lastSends", { n: logs.length }) }}</span>
|
||||
<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">No send records yet — trigger a webhook to see results.</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.ts + l.routeId" class="log-entry" :class="{ fail: !l.ok }">
|
||||
|
|
@ -17,8 +17,8 @@
|
|||
<span class="log-time">{{ fmtTime(l.ts) }}</span>
|
||||
</div>
|
||||
<div class="log-meta">
|
||||
<span v-if="l.repo"><b>REPO</b><code>{{ l.repo }}</code></span>
|
||||
<span><b>TARGET</b><code>{{ l.target }}</code></span>
|
||||
<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>
|
||||
|
|
@ -29,6 +29,8 @@
|
|||
<script setup lang="ts">
|
||||
import type { SendRecord } from "~/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
|
||||
const emit = defineEmits<{ (e: "refresh"): void }>();
|
||||
|
||||
|
|
|
|||
246
admin/composables/useI18n.ts
Normal file
246
admin/composables/useI18n.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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",
|
||||
"status.loading": "loading",
|
||||
"status.connected": "connected",
|
||||
"kpi.groups": "GROUPS",
|
||||
"kpi.routes": "ROUTES",
|
||||
"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.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",
|
||||
"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.name": "Name",
|
||||
"routeEditor.namePlaceholder": "My Route",
|
||||
"routeEditor.id": "ID",
|
||||
"routeEditor.idHint": "Unique, use a-z / 0-9 / dashes",
|
||||
"routeEditor.language": "Language",
|
||||
"routeEditor.langPlaceholder": "en",
|
||||
"routeEditor.langHint": "en or zh; custom via KV i18n:<lang>",
|
||||
"routeEditor.enabled": "Route enabled",
|
||||
"routeEditor.filters": "Filters",
|
||||
"routeEditor.filtersNote": "(all must match · AND)",
|
||||
"routeEditor.matchPlaceholder": "match value",
|
||||
"routeEditor.not": "NOT",
|
||||
"routeEditor.addFilter": "+ Add filter",
|
||||
"routeEditor.channel": "Channel ID",
|
||||
"routeEditor.channelPlaceholder": "Discord channel ID",
|
||||
"routeEditor.thread": "Thread ID",
|
||||
"routeEditor.threadNote": "(optional)",
|
||||
"routeEditor.threadPlaceholder": "Optional thread ID",
|
||||
"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": "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.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.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.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",
|
||||
"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": "发送日志",
|
||||
"status.loading": "加载中",
|
||||
"status.connected": "已连接",
|
||||
"kpi.groups": "分组",
|
||||
"kpi.routes": "路由",
|
||||
"groups.empty": "还没有分组。分组按组织/用户限定路由并委派访问权限。",
|
||||
"groups.createFirst": "创建第一个分组",
|
||||
"groups.admins": "管理员",
|
||||
"groups.owners": "来源",
|
||||
"groups.any": "任意",
|
||||
"groups.open": "进入 →",
|
||||
"group.back": "← 返回分组",
|
||||
"group.routesIn": "{name} 的路由",
|
||||
"routes.emptyGroup": "该分组还没有路由。",
|
||||
"routes.createFirst": "创建第一个路由",
|
||||
"route.untitled": "(未命名)",
|
||||
"route.noFilters": "无过滤器",
|
||||
"route.channel": "频道",
|
||||
"route.thread": "子区",
|
||||
"filter.event": "事件",
|
||||
"filter.repo": "仓库",
|
||||
"filter.actor": "操作者",
|
||||
"filter.action": "动作",
|
||||
"filter.branch": "分支",
|
||||
"filter.keyword": "关键词",
|
||||
"routeEditor.editTitle": "编辑路由",
|
||||
"routeEditor.newTitle": "新建路由",
|
||||
"routeEditor.close": "关闭",
|
||||
"routeEditor.name": "名称",
|
||||
"routeEditor.namePlaceholder": "我的路由",
|
||||
"routeEditor.id": "ID",
|
||||
"routeEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线",
|
||||
"routeEditor.language": "语言",
|
||||
"routeEditor.langPlaceholder": "zh",
|
||||
"routeEditor.langHint": "en 或 zh;可通过 KV i18n:<lang> 自定义",
|
||||
"routeEditor.enabled": "启用路由",
|
||||
"routeEditor.filters": "过滤器",
|
||||
"routeEditor.filtersNote": "(全部匹配 · AND)",
|
||||
"routeEditor.matchPlaceholder": "匹配值",
|
||||
"routeEditor.not": "取反",
|
||||
"routeEditor.addFilter": "+ 添加过滤器",
|
||||
"routeEditor.channel": "频道 ID",
|
||||
"routeEditor.channelPlaceholder": "Discord 频道 ID",
|
||||
"routeEditor.thread": "子区 ID",
|
||||
"routeEditor.threadNote": "(可选)",
|
||||
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
||||
"routeEditor.cancel": "取消",
|
||||
"routeEditor.save": "保存路由",
|
||||
"routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值",
|
||||
"routeEditor.errAddFilter": "至少添加一个过滤器",
|
||||
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||
"routeEditor.errName": "名称为必填项",
|
||||
"routeEditor.errChannel": "频道 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.admins": "分组管理员",
|
||||
"groupEditor.adminsNote": "(GitHub 登录名或用户 ID,逗号分隔)",
|
||||
"groupEditor.adminsPlaceholder": "octocat, 12345",
|
||||
"groupEditor.adminsHint": "这些用户可以查看日志并编辑本分组的路由。",
|
||||
"groupEditor.owners": "来源限定",
|
||||
"groupEditor.ownersNote": "(GitHub 组织 / 用户登录名,逗号分隔)",
|
||||
"groupEditor.ownersPlaceholder": "my-org, some-user",
|
||||
"groupEditor.ownersHint": "只有来自这些组织/用户的 webhook 事件才会进入本分组的路由。留空表示不限制。",
|
||||
"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": "目标",
|
||||
"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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue