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

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

107
server/lib/lib/audit.ts Normal file
View file

@ -0,0 +1,107 @@
import { log } from "./log";
export interface AuditEntry {
id?: number;
ts: number;
actorId?: string;
actorLogin?: string;
/** Machine-readable action, e.g. "session.login", "group.update", "invite.create". */
action: string;
targetType?: string;
targetId?: string;
groupId?: string;
/** Free-form metadata. Never include secrets or message bodies. */
detail?: Record<string, unknown>;
ip?: string;
}
const COLUMNS =
"id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow {
id: number;
ts: number;
actor_id: string | null;
actor_login: string | null;
action: string;
target_type: string | null;
target_id: string | null;
group_id: string | null;
detail: string | null;
ip: string | null;
}
function toEntry(r: AuditRow): AuditEntry {
return {
id: r.id,
ts: r.ts,
actorId: r.actor_id ?? undefined,
actorLogin: r.actor_login ?? undefined,
action: r.action,
targetType: r.target_type ?? undefined,
targetId: r.target_id ?? undefined,
groupId: r.group_id ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
ip: r.ip ?? undefined,
};
}
/** Best-effort write; failures never break the business flow. */
export async function recordAudit(db: D1Database, entry: AuditEntry): Promise<void> {
try {
await db
.prepare(
`INSERT INTO audit_logs (ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
entry.ts,
entry.actorId ?? null,
entry.actorLogin ?? null,
entry.action,
entry.targetType ?? null,
entry.targetId ?? null,
entry.groupId ?? null,
entry.detail ? JSON.stringify(entry.detail) : null,
entry.ip ?? null,
)
.run();
} catch (err) {
log.warn({ err, action: entry.action }, "Failed to record audit entry");
}
}
export async function getAuditLog(
db: D1Database,
opts: { groupId?: string; limit?: number } = {},
): Promise<AuditEntry[]> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
try {
if (opts.groupId) {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs WHERE group_id = ? ORDER BY ts DESC LIMIT ?`)
.bind(opts.groupId, limit)
.all<AuditRow>();
return results.map(toEntry);
}
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<AuditRow>();
return results.map(toEntry);
} catch (err) {
log.warn({ err }, "Failed to load audit log");
return [];
}
}
export async function pruneAuditLogs(db: D1Database, retentionDays: number): Promise<number> {
const cutoff = Date.now() - retentionDays * 86400_000;
try {
const { meta } = await db.prepare("DELETE FROM audit_logs WHERE ts < ?").bind(cutoff).run();
return meta.changes;
} catch (err) {
log.warn({ err }, "Failed to prune audit logs");
return 0;
}
}

63
server/lib/lib/i18n.ts Normal file
View file

@ -0,0 +1,63 @@
import { en } from "./locales/en";
import { zh } from "./locales/zh";
type NestedStrings = { [key: string]: string | NestedStrings };
export type Translations = NestedStrings;
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(template: string, params: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
return key in params ? String(params[key]) : `{${key}}`;
});
}
const cache = new Map<string, Translations>();
export async function loadTranslations(
lang: string,
kv?: { get<T>(key: string, type: "json"): Promise<T | null> },
): Promise<Translations> {
if (lang === "en") return en;
if (lang === "zh") return zh;
const cached = cache.get(lang);
if (cached) return cached;
if (!kv) return en;
try {
const stored = await kv.get<Partial<Translations>>(`i18n:${lang}`, "json");
if (stored) {
const merged = { ...en, ...stored } as Translations;
cache.set(lang, merged);
return merged;
}
} catch {
// KV read failed, fall back to EN
}
return en;
}
export function t(
key: string,
params?: Record<string, string | number>,
lang?: string | null,
translations?: Translations,
): string {
const dict = translations ?? en;
const raw =
getByPath(dict as Record<string, unknown>, key) ??
getByPath(en as Record<string, unknown>, key) ??
key;
return params ? interpolate(raw, params) : raw;
}

View file

@ -0,0 +1,208 @@
export const en = {
actions: {
opened: "Opened",
closed: "Closed",
reopened: "Reopened",
synchronized: "Synchronized",
edited: "Edited",
labeled: "Labeled",
unlabeled: "Unlabeled",
assigned: "Assigned",
unassigned: "Unassigned",
converted_to_draft: "Converted to Draft",
ready_for_review: "Ready for Review",
completed: "Completed",
published: "Published",
created: "Created",
deleted: "Deleted",
started: "Started",
added: "Added",
removed: "Removed",
submitted: "Submitted",
dismissed: "Dismissed",
approved: "Approved",
changes_requested: "Changes Requested",
answered: "Answered",
unanswered: "Unanswered",
pinned: "Pinned",
unpinned: "Unpinned",
transferred: "Transferred",
publicized: "made public",
privatized: "made private",
locked: "Locked",
unlocked: "Unlocked",
renamed: "Renamed",
archived: "Archived",
unarchived: "Unarchived",
fixed: "Fixed",
appeared_in_branch: "Appeared in Branch",
reopened_by_user: "Reopened by User",
closed_by_user: "Closed by User",
},
fields: {
branch: "Branch",
changes: "Changes",
labels: "Labels",
assignees: "Assignees",
milestone: "Milestone",
status: "Status",
run: "Run",
job: "Job",
workflow: "Workflow",
context: "Context",
duration: "Duration",
type: "Type",
name: "Name",
description: "Description",
file: "File",
commit: "Commit",
environment: "Environment",
url: "URL",
service: "Service",
severity: "Severity",
rule: "Rule",
package: "Package",
vulnerable_range: "Vulnerable Range",
summary: "Summary",
progress: "Progress",
due: "Due",
number: "Number",
color: "Color",
label: "Label",
transferred: "Transferred",
renamed: "Renamed",
details: "Details",
branch_tag: "Branch/Tag",
},
common: {
footer: "{repo}",
unknown: "unknown",
no_message: "no message",
repository: "repository",
untitled: "Untitled",
github: "GitHub",
and_n_more: "... and {count} more commits",
n_files: "{count} files",
},
events: {
push: {
force_push: "**Force push**",
branch_created: "Branch created",
commits_pushed: "**{count}** commit{s} pushed to {ref}",
view_comparison: "[View comparison]({url})",
added: "+{count} added",
removed: "-{count} removed",
modified: "~{count} modified",
title: "{repo}: Pushed {count} commit{s}",
},
pr: {
action_pr: "{emoji}**{action}** pull request",
title: "{repo}#{number}: {title}",
},
issues: {
action_issue: "{emoji}**{action}** issue",
title: "{repo}#{number}: {title}",
},
issue_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** comment",
},
workflow_run: {
title: "{repo}: {name} — {conclusion}",
},
workflow_job: {
title: "{repo}: Job {name} — {conclusion}",
},
status: {
title: "{repo}: {context} — {state}",
},
ping: {
title: "{repo}: Webhook ping",
},
release: {
action_release: "{emoji}**{action}** release `{tag}`",
title: "{repo}: {name}",
},
create: {
title: "{repo}: {emoji}Created {type} {ref}",
},
delete: {
title: "{repo}: {emoji}Deleted {type} {ref}",
},
star: {
starred: "Starred",
unstarred: "Unstarred",
title: "{repo}: {emoji}{label}",
},
fork: {
title: "{repo}: {emoji}Forked to {forkee}",
},
check_run: {
title: "{repo}: {name} — {conclusion}",
},
check_suite: {
title: "{repo}: Check suite {conclusion}",
},
pr_review: {
action_review: "{emoji}**{action}** review",
title: "{repo}#{number}: {title}",
},
pr_review_comment: {
action_inline: "{emoji}**{action}** inline comment",
title: "{repo}#{number}: {title}",
line: " (line {position})",
},
commit_comment: {
action_comment: "{emoji}**{action}**",
title: "{repo}: Comment on commit {sha}",
},
deployment: {
title: "{repo}: Deployment to `{env}` — {state}",
},
member: {
title: "{repo}: {emoji}{action} collaborator: {name}",
},
label: {
title: "{repo}: {emoji}Label {action}: {name}",
},
milestone: {
title: "{repo}: {emoji}Milestone {action}: {title}",
},
discussion: {
title: "{repo}#{number}: {title}",
action_discussion: "{emoji}{action} discussion{category}",
},
discussion_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** comment",
},
repository: {
title: "{repo}: {emoji}Repository {action}",
open: "Open repository",
public: "public",
private: "private",
internal: "internal",
is_fork: "This is a fork",
visibility: "Visibility",
},
code_scanning: {
title: "{repo}: Code Scanning {action}",
},
dependabot: {
title: "{repo}: Dependabot {action}",
},
generic: {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "Custom message",
},
log: {
title: "{repo}: {event}{action}",
routes: "Routes",
delivery: "Delivery",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

View file

@ -0,0 +1,208 @@
export const zh = {
actions: {
opened: "已打开",
closed: "已关闭",
reopened: "重新打开",
synchronized: "已同步",
edited: "已编辑",
labeled: "已添加标签",
unlabeled: "已移除标签",
assigned: "已分配",
unassigned: "已取消分配",
converted_to_draft: "已转为草稿",
ready_for_review: "可供审查",
completed: "已完成",
published: "已发布",
created: "已创建",
deleted: "已删除",
started: "已开始",
added: "已添加",
removed: "已移除",
submitted: "已提交",
dismissed: "已忽略",
approved: "已批准",
changes_requested: "请求修改",
answered: "已回答",
unanswered: "未回答",
pinned: "已置顶",
unpinned: "已取消置顶",
transferred: "已转移",
publicized: "已公开",
privatized: "已设为私有",
locked: "已锁定",
unlocked: "已解锁",
renamed: "已重命名",
archived: "已归档",
unarchived: "已取消归档",
fixed: "已修复",
appeared_in_branch: "出现在分支中",
reopened_by_user: "用户重新打开",
closed_by_user: "用户关闭",
},
fields: {
branch: "分支",
changes: "变更",
labels: "标签",
assignees: "指派人",
milestone: "里程碑",
status: "状态",
run: "运行",
job: "作业",
workflow: "工作流",
context: "上下文",
duration: "耗时",
type: "类型",
name: "名称",
description: "描述",
file: "文件",
commit: "提交",
environment: "环境",
url: "链接",
service: "服务",
severity: "严重程度",
rule: "规则",
package: "包",
vulnerable_range: "受影响版本",
summary: "摘要",
progress: "进度",
due: "截止日期",
number: "编号",
color: "颜色",
label: "标签",
transferred: "已转移",
renamed: "已重命名",
details: "详情",
branch_tag: "分支/标签",
},
common: {
footer: "{repo}",
unknown: "未知",
no_message: "无消息",
repository: "仓库",
untitled: "无标题",
github: "GitHub",
and_n_more: "... 还有 {count} 个提交",
n_files: "{count} 个文件",
},
events: {
push: {
force_push: "**强制推送**",
branch_created: "分支已创建",
commits_pushed: "**{count}** 个提交已推送到 {ref}",
view_comparison: "[查看比较]({url})",
added: "+{count} 新增",
removed: "-{count} 删除",
modified: "~{count} 修改",
title: "{repo}: 推送了 {count} 个提交",
},
pr: {
action_pr: "{emoji}**{action}** 拉取请求",
title: "{repo}#{number}: {title}",
},
issues: {
action_issue: "{emoji}**{action}** 议题",
title: "{repo}#{number}: {title}",
},
issue_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** 评论",
},
workflow_run: {
title: "{repo}: {name} — {conclusion}",
},
workflow_job: {
title: "{repo}: 作业 {name} — {conclusion}",
},
status: {
title: "{repo}: {context} — {state}",
},
ping: {
title: "{repo}: Webhook ping",
},
release: {
action_release: "{emoji}**{action}** 发布 `{tag}`",
title: "{repo}: {name}",
},
create: {
title: "{repo}: {emoji}已创建{type} {ref}",
},
delete: {
title: "{repo}: {emoji}已删除{type} {ref}",
},
star: {
starred: "已加星标",
unstarred: "已取消星标",
title: "{repo}: {emoji}{label}",
},
fork: {
title: "{repo}: {emoji}复刻到 {forkee}",
},
check_run: {
title: "{repo}: {name} — {conclusion}",
},
check_suite: {
title: "{repo}: 检查套件 {conclusion}",
},
pr_review: {
action_review: "{emoji}**{action}** 审查",
title: "{repo}#{number}: {title}",
},
pr_review_comment: {
action_inline: "{emoji}**{action}** 行内评论",
title: "{repo}#{number}: {title}",
line: " (第 {position} 行)",
},
commit_comment: {
action_comment: "{emoji}**{action}**",
title: "{repo}: 提交 {sha} 的评论",
},
deployment: {
title: "{repo}: 部署到 `{env}` — {state}",
},
member: {
title: "{repo}: {emoji}{action} 协作者: {name}",
},
label: {
title: "{repo}: {emoji}标签 {action}: {name}",
},
milestone: {
title: "{repo}: {emoji}里程碑 {action}: {title}",
},
discussion: {
title: "{repo}#{number}: {title}",
action_discussion: "{emoji}{action} 讨论{category}",
},
discussion_comment: {
title: "{repo}#{number}: {title}",
action_comment: "{emoji}**{action}** 评论",
},
repository: {
title: "{repo}: {emoji}仓库 {action}",
open: "打开仓库",
public: "公开",
private: "私有",
internal: "内部",
is_fork: "这是 Fork 仓库",
visibility: "可见性",
},
code_scanning: {
title: "{repo}: 代码扫描 {action}",
},
dependabot: {
title: "{repo}: Dependabot {action}",
},
generic: {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "自定义消息",
},
log: {
title: "{repo}: {event}{action}",
routes: "路由",
delivery: "投递",
route_ok: "✅ {route} → {target}",
route_fail: "❌ {route} → {target}: {error}",
},
};

10
server/lib/lib/log.ts Normal file
View file

@ -0,0 +1,10 @@
export const log = {
info: (msg: string | object, ...args: unknown[]): void =>
console.log(JSON.stringify({ level: "info", msg, ...args })),
warn: (msg: string | object, ...args: unknown[]): void =>
console.warn(JSON.stringify({ level: "warn", msg, ...args })),
error: (msg: string | object, ...args: unknown[]): void =>
console.error(JSON.stringify({ level: "error", msg, ...args })),
fatal: (msg: string | object, ...args: unknown[]): void =>
console.error(JSON.stringify({ level: "fatal", msg, ...args })),
};

132
server/lib/lib/send-log.ts Normal file
View file

@ -0,0 +1,132 @@
import { log } from "./log";
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>;
}
const COLUMNS =
"id, ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail";
interface LogRow {
id: number;
ts: number;
route_id: string;
group_id: string | null;
event: string;
repo: string | null;
target: string;
ok: number;
error: string | null;
status: number | null;
message_id: string | null;
delivery_id: string | null;
platform: string | null;
actor: string | null;
action: string | null;
duration_ms: number | null;
error_code: string | null;
attempts: number | null;
detail: string | null;
}
function toRecord(r: LogRow): SendRecord {
return {
id: r.id,
ts: r.ts,
routeId: r.route_id,
groupId: r.group_id ?? undefined,
event: r.event,
repo: r.repo ?? undefined,
target: r.target,
ok: r.ok === 1,
error: r.error ?? undefined,
status: r.status ?? undefined,
messageId: r.message_id ?? undefined,
deliveryId: r.delivery_id ?? undefined,
platform: r.platform ?? undefined,
actor: r.actor ?? undefined,
action: r.action ?? undefined,
durationMs: r.duration_ms ?? undefined,
errorCode: r.error_code ?? undefined,
attempts: r.attempts ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
};
}
export async function recordSend(db: D1Database, record: SendRecord): Promise<void> {
try {
await db
.prepare(
`INSERT INTO send_logs (ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
record.ts,
record.routeId,
record.groupId ?? null,
record.event,
record.repo ?? null,
record.target,
record.ok ? 1 : 0,
record.error ?? null,
record.status ?? null,
record.messageId ?? null,
record.deliveryId ?? null,
record.platform ?? null,
record.actor ?? null,
record.action ?? null,
record.durationMs ?? null,
record.errorCode ?? null,
record.attempts ?? null,
record.detail ? JSON.stringify(record.detail) : null,
)
.run();
} catch (err) {
log.warn({ err }, "Failed to record send log");
}
}
export async function getSendLog(db: D1Database, limit = 50): Promise<SendRecord[]> {
try {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM send_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<LogRow>();
return results.map(toRecord);
} catch (err) {
log.warn({ err }, "Failed to load send log");
return [];
}
}
export async function getSendLogById(db: D1Database, id: number): Promise<SendRecord | null> {
try {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM send_logs WHERE id = ? LIMIT 1`)
.bind(id)
.all<LogRow>();
const row = results[0];
return row ? toRecord(row) : null;
} catch (err) {
log.warn({ err, id }, "Failed to load send log entry");
return null;
}
}