mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat: add i18n support (zh-CN/en) with language switcher
- Add lib/i18n.tsx with translation dictionaries, I18nProvider, useI18n hook, and LanguageSwitcher component - Add app/providers.tsx client-side provider wrapper - Internationalize all pages: home, models, stats, sessions, skills - Update layout.tsx with I18nProvider - Default language: Chinese, switchable to English via button - Language preference persisted in localStorage - Fix DayStat type error in stats API route
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
|
||||
|
||||
// Find OpenClaw package directory
|
||||
function findOpenClawPkg(): string {
|
||||
// Check common locations
|
||||
const candidates = [
|
||||
path.join(process.env.HOME || "", ".nvm/versions/node", process.version, "lib/node_modules/openclaw"),
|
||||
"/usr/local/lib/node_modules/openclaw",
|
||||
"/usr/lib/node_modules/openclaw",
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, "package.json"))) return c;
|
||||
}
|
||||
// Fallback: try to find via which
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const OPENCLAW_PKG = findOpenClawPkg();
|
||||
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string; // "builtin" | "extension" | "custom"
|
||||
location: string;
|
||||
usedBy: string[]; // agent ids
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!content.startsWith("---")) return result;
|
||||
const parts = content.split("---", 3);
|
||||
if (parts.length < 3) return result;
|
||||
const fm = parts[1];
|
||||
|
||||
const nameMatch = fm.match(/^name:\s*(.+)/m);
|
||||
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
|
||||
if (emojiMatch) result.emoji = emojiMatch[1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
for (const name of fs.readdirSync(dir).sort()) {
|
||||
const skillMd = path.join(dir, name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMd)) continue;
|
||||
const content = fs.readFileSync(skillMd, "utf-8");
|
||||
const fm = parseFrontmatter(content);
|
||||
skills.push({
|
||||
id: name,
|
||||
name: fm.name || name,
|
||||
description: fm.description || "",
|
||||
emoji: fm.emoji || "🔧",
|
||||
source,
|
||||
location: skillMd,
|
||||
usedBy: [],
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
// Parse skillsSnapshot from session JSONL files
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
if (!fs.existsSync(agentsDir)) return result;
|
||||
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
if (!fs.existsSync(sessionsDir)) continue;
|
||||
|
||||
const jsonlFiles = fs.readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith(".jsonl"))
|
||||
.sort();
|
||||
const skillNames = new Set<string>();
|
||||
|
||||
// Check the most recent session files for skillsSnapshot
|
||||
for (const file of jsonlFiles.slice(-3)) {
|
||||
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
|
||||
const idx = content.indexOf("skillsSnapshot");
|
||||
if (idx < 0) continue;
|
||||
const chunk = content.slice(idx, idx + 5000);
|
||||
// Match skill names in escaped JSON: \"name\":\"xxx\" or "name":"xxx"
|
||||
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
|
||||
for (const m of matches) {
|
||||
const name = m[1];
|
||||
// Filter out tool names and other non-skill entries
|
||||
if (!["exec","read","edit","write","process","message","web_search","web_fetch",
|
||||
"browser","tts","gateway","memory_search","memory_get","cron","nodes",
|
||||
"canvas","session_status","sessions_list","sessions_history","sessions_send",
|
||||
"sessions_spawn","agents_list"].includes(name) && name.length > 1) {
|
||||
skillNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skillNames.size > 0) {
|
||||
result[agentId] = skillNames;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. Scan builtin skills
|
||||
const builtinDir = path.join(OPENCLAW_PKG, "skills");
|
||||
const builtinSkills = scanSkillsDir(builtinDir, "builtin");
|
||||
|
||||
// 2. Scan extension skills
|
||||
const extDir = path.join(OPENCLAW_PKG, "extensions");
|
||||
const extSkills: SkillInfo[] = [];
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const ext of fs.readdirSync(extDir)) {
|
||||
const skillsDir = path.join(extDir, ext, "skills");
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const skills = scanSkillsDir(skillsDir, `extension:${ext}`);
|
||||
extSkills.push(...skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan custom skills (~/.openclaw/skills)
|
||||
const customDir = path.join(OPENCLAW_HOME, "skills");
|
||||
const customSkills = scanSkillsDir(customDir, "custom");
|
||||
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
|
||||
// 4. Map agent usage from session data
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
for (const [agentId, skills] of Object.entries(agentSkills)) {
|
||||
if (skills.has(skill.id) || skills.has(skill.name)) {
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Get agent info for display
|
||||
const configPath = path.join(OPENCLAW_HOME, "openclaw.json");
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const agentList = config.agents?.list || [];
|
||||
const agentMap: Record<string, { name: string; emoji: string }> = {};
|
||||
for (const a of agentList) {
|
||||
const name = a.identity?.name || a.name || a.id;
|
||||
const emoji = a.identity?.emoji || "🤖";
|
||||
agentMap[a.id] = { name, emoji };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
skills: allSkills,
|
||||
agents: agentMap,
|
||||
total: allSkills.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface DayStat {
|
||||
responseTimes: number[]; // internal, stripped before response
|
||||
}
|
||||
|
||||
function parseSessions(agentId: string): DayStat[] {
|
||||
function parseSessions(agentId: string): Omit<DayStat, "responseTimes">[] {
|
||||
const sessionsDir = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions`);
|
||||
const dayMap: Record<string, DayStat> = {};
|
||||
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenClaw Bot Dashboard",
|
||||
@@ -9,7 +10,9 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+26
-27
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
@@ -61,6 +62,7 @@ function formatMs(ms: number): string {
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigData | null>(null);
|
||||
const [modelStats, setModelStats] = useState<Record<string, ModelStat>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -88,18 +90,15 @@ export default function ModelsPage() {
|
||||
|
||||
const testAllModels = async () => {
|
||||
if (!data) return;
|
||||
// Collect all model+provider pairs
|
||||
const pairs: { provider: string; modelId: string }[] = [];
|
||||
for (const p of data.providers) {
|
||||
if (p.models.length > 0) {
|
||||
for (const m of p.models) pairs.push({ provider: p.id, modelId: m.id });
|
||||
} else {
|
||||
// For providers without explicit models, check modelStats for known models
|
||||
const knownModels = Object.values(modelStats).filter(s => s.provider === p.id);
|
||||
for (const s of knownModels) pairs.push({ provider: s.provider, modelId: s.modelId });
|
||||
}
|
||||
}
|
||||
// Run all tests in parallel
|
||||
for (const pair of pairs) {
|
||||
testModel(pair.provider, pair.modelId);
|
||||
}
|
||||
@@ -127,7 +126,7 @@ export default function ModelsPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">加载失败: {error}</p>
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +134,7 @@ export default function ModelsPage() {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)]">加载中...</p>
|
||||
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,10 +144,10 @@ export default function ModelsPage() {
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
OpenClaw接入模型列表
|
||||
{t("models.title")}
|
||||
</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {data.providers.length} 个 Provider
|
||||
{t("models.totalPrefix")} {data.providers.length} {t("models.providerCount")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -161,14 +160,15 @@ export default function ModelsPage() {
|
||||
: "bg-[var(--accent)] text-[var(--bg)] hover:opacity-90 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{Object.values(testing).some(Boolean) ? "⏳ 测试中..." : "🧪 测试全部模型"}
|
||||
{Object.values(testing).some(Boolean) ? t("models.testingAll") : t("models.testAll")}
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
>
|
||||
← 返回总览
|
||||
{t("common.backOverview")}
|
||||
</Link>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
{provider.usedBy.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-[var(--text-muted)] mr-1">使用中:</span>
|
||||
<span className="text-xs text-[var(--text-muted)] mr-1">{t("agent.inUse")}</span>
|
||||
{provider.usedBy.map((a) => (
|
||||
<span key={a.id} title={a.id} className="px-2 py-0.5 rounded-full bg-[var(--bg)] text-xs font-medium">
|
||||
{a.emoji} {a.name || a.id}
|
||||
@@ -202,16 +202,16 @@ export default function ModelsPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--text-muted)] text-xs border-b border-[var(--border)]">
|
||||
<th className="text-left py-2 pr-4">模型 ID</th>
|
||||
<th className="text-left py-2 pr-4">名称</th>
|
||||
<th className="text-left py-2 pr-4">上下文窗口</th>
|
||||
<th className="text-left py-2 pr-4">最大输出</th>
|
||||
<th className="text-left py-2 pr-4">输入类型</th>
|
||||
<th className="text-left py-2 pr-4">推理</th>
|
||||
<th className="text-right py-2 pr-4">Input 用量</th>
|
||||
<th className="text-right py-2 pr-4">Output 用量</th>
|
||||
<th className="text-right py-2 pr-4">平均响应</th>
|
||||
<th className="text-center py-2">测试</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colModelId")}</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colName")}</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colContext")}</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colMaxOutput")}</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colInputType")}</th>
|
||||
<th className="text-left py-2 pr-4">{t("models.colReasoning")}</th>
|
||||
<th className="text-right py-2 pr-4">{t("models.colInputToken")}</th>
|
||||
<th className="text-right py-2 pr-4">{t("models.colOutputToken")}</th>
|
||||
<th className="text-right py-2 pr-4">{t("models.colAvgResponse")}</th>
|
||||
<th className="text-center py-2">{t("models.colTest")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -228,12 +228,12 @@ export default function ModelsPage() {
|
||||
<td className="py-2 pr-4">{formatNum(m.maxTokens)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex gap-1">
|
||||
{(m.input || []).map((t) => (
|
||||
{(m.input || []).map((inputType) => (
|
||||
<span
|
||||
key={t}
|
||||
key={inputType}
|
||||
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-xs"
|
||||
>
|
||||
{t === "text" ? "📝" : "🖼️"} {t}
|
||||
{inputType === "text" ? "📝" : "🖼️"} {inputType}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export default function ModelsPage() {
|
||||
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isTesting ? "⏳ 测试中..." : "🧪 测试"}
|
||||
{isTesting ? t("common.testing") : t("common.test")}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`text-[10px] max-w-[140px] truncate ${result.ok ? "text-green-400" : "text-red-400"}`} title={result.ok ? result.text : result.error}>
|
||||
@@ -271,10 +271,9 @@ export default function ModelsPage() {
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-[var(--text-muted)] text-sm">
|
||||
无显式模型定义(通过 provider 名称推断)
|
||||
{t("models.noExplicitModels")}
|
||||
</p>
|
||||
{(() => {
|
||||
// Show aggregated stats for this provider even without explicit model defs
|
||||
const providerStats = Object.values(modelStats).filter(s => s.provider === provider.id);
|
||||
if (providerStats.length === 0) return null;
|
||||
const totalInput = providerStats.reduce((s, m) => s + m.inputTokens, 0);
|
||||
@@ -302,7 +301,7 @@ export default function ModelsPage() {
|
||||
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isTesting ? "⏳" : "🧪 测试"}
|
||||
{isTesting ? "⏳" : t("common.test")}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`text-[10px] ${result.ok ? "text-green-400" : "text-red-400"}`} title={result.ok ? result.text : result.error}>
|
||||
|
||||
+71
-61
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
|
||||
|
||||
interface Platform {
|
||||
name: string;
|
||||
@@ -54,7 +55,8 @@ interface AllStats {
|
||||
}
|
||||
|
||||
type TimeRange = "daily" | "weekly" | "monthly";
|
||||
const RANGE_LABELS: Record<TimeRange, string> = { daily: "按天", weekly: "按周", monthly: "按月" };
|
||||
|
||||
type TFunc = (key: string) => string;
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
@@ -69,8 +71,8 @@ function formatMs(ms: number): string {
|
||||
}
|
||||
|
||||
// 趋势折线图
|
||||
function TrendChart({ data, lines, height = 180 }: { data: DayStat[]; lines: { key: keyof DayStat; color: string; label: string }[]; height?: number }) {
|
||||
if (data.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">暂无数据</div>;
|
||||
function TrendChart({ data, lines, height = 180, t }: { data: DayStat[]; lines: { key: keyof DayStat; color: string; label: string }[]; height?: number; t: TFunc }) {
|
||||
if (data.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">{t("common.noData")}</div>;
|
||||
|
||||
const pad = { top: 16, right: 16, bottom: 50, left: 56 };
|
||||
const width = Math.max(500, data.length * 56 + pad.left + pad.right);
|
||||
@@ -116,9 +118,9 @@ function TrendChart({ data, lines, height = 180 }: { data: DayStat[]; lines: { k
|
||||
}
|
||||
|
||||
// 响应时间趋势图
|
||||
function ResponseTrendChart({ data, height = 180 }: { data: DayStat[]; height?: number }) {
|
||||
function ResponseTrendChart({ data, height = 180, t }: { data: DayStat[]; height?: number; t: TFunc }) {
|
||||
const filtered = data.filter(d => d.avgResponseMs > 0);
|
||||
if (filtered.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">暂无响应时间数据</div>;
|
||||
if (filtered.length === 0) return <div className="flex items-center justify-center h-32 text-[var(--text-muted)] text-sm">{t("home.noResponseData")}</div>;
|
||||
|
||||
const pad = { top: 16, right: 16, bottom: 50, left: 56 };
|
||||
const width = Math.max(500, filtered.length * 56 + pad.left + pad.right);
|
||||
@@ -158,20 +160,8 @@ function ResponseTrendChart({ data, height = 180 }: { data: DayStat[]; height?:
|
||||
);
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
// 平台标签颜色(可点击跳转到对应平台的 session chat 页面)
|
||||
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string }) {
|
||||
// 平台标签颜色
|
||||
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc }) {
|
||||
const isFeishu = platform.name === "feishu";
|
||||
|
||||
let sessionKey: string;
|
||||
@@ -191,14 +181,14 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken }: { platf
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="点击打开聊天页面"
|
||||
title={t("agent.openChat")}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${
|
||||
isFeishu
|
||||
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
|
||||
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400"
|
||||
}`}
|
||||
>
|
||||
{isFeishu ? "📱 飞书" : "🎮 Discord"}
|
||||
{isFeishu ? t("platform.feishu") : t("platform.discord")}
|
||||
{platform.accountId && (
|
||||
<span className="opacity-60">({platform.accountId})</span>
|
||||
)}
|
||||
@@ -231,12 +221,23 @@ function ModelBadge({ model }: { model: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Agent 卡片(点击跳转到 agent 的 main session chat 页面)
|
||||
function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gatewayPort: number; gatewayToken?: string }) {
|
||||
// Agent 卡片
|
||||
function AgentCard({ agent, gatewayPort, gatewayToken, t }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc }) {
|
||||
const sessionKey = `agent:${agent.id}:main`;
|
||||
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
|
||||
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t("common.justNow");
|
||||
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${t("common.daysAgo")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => window.open(sessionUrl, "_blank")}
|
||||
@@ -254,22 +255,22 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">模型</span>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.model")}</span>
|
||||
<ModelBadge model={agent.model} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">平台</span>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.platforms.map((p, i) => (
|
||||
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} />
|
||||
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.platforms.some((p) => p.appId) && (
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">飞书 App ID</span>
|
||||
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.feishuAppId")}</span>
|
||||
<code className="text-xs text-[var(--accent)] bg-[var(--bg)] px-2 py-0.5 rounded">
|
||||
{agent.platforms.find((p) => p.appId)?.appId}
|
||||
</code>
|
||||
@@ -279,7 +280,7 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
|
||||
{agent.session && (
|
||||
<div className="pt-2 mt-2 border-t border-[var(--border)]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--text-muted)]">会话数</span>
|
||||
<span className="text-[var(--text-muted)]">{t("agent.sessionCount")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/sessions?agent=${agent.id}`}
|
||||
@@ -293,17 +294,17 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[var(--accent)] hover:underline cursor-pointer text-[10px]"
|
||||
>
|
||||
📊 统计
|
||||
📊 {t("agent.stats")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">Token 用量</span>
|
||||
<span className="text-[var(--text-muted)]">{t("agent.tokenUsage")}</span>
|
||||
<span className="text-[var(--text)]">{(agent.session.totalTokens / 1000).toFixed(1)}k</span>
|
||||
</div>
|
||||
{agent.session.lastActive && (
|
||||
<div className="flex items-center justify-between text-xs mt-1">
|
||||
<span className="text-[var(--text-muted)]">最近活跃</span>
|
||||
<span className="text-[var(--text-muted)]">{t("agent.lastActive")}</span>
|
||||
<span className="text-[var(--text)]">{formatTimeAgo(agent.session.lastActive)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -314,17 +315,8 @@ function AgentCard({ agent, gatewayPort, gatewayToken }: { agent: Agent; gateway
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新选项
|
||||
const REFRESH_OPTIONS = [
|
||||
{ label: "手动刷新", value: 0 },
|
||||
{ label: "10 秒", value: 10 },
|
||||
{ label: "30 秒", value: 30 },
|
||||
{ label: "1 分钟", value: 60 },
|
||||
{ label: "5 分钟", value: 300 },
|
||||
{ label: "10 分钟", value: 600 },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ConfigData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState(0);
|
||||
@@ -334,6 +326,17 @@ export default function Home() {
|
||||
const [statsRange, setStatsRange] = useState<TimeRange>("daily");
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
|
||||
|
||||
const REFRESH_OPTIONS = [
|
||||
{ label: t("refresh.manual"), value: 0 },
|
||||
{ label: t("refresh.10s"), value: 10 },
|
||||
{ label: t("refresh.30s"), value: 30 },
|
||||
{ label: t("refresh.1m"), value: 60 },
|
||||
{ label: t("refresh.5m"), value: 300 },
|
||||
{ label: t("refresh.10m"), value: 600 },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
@@ -365,7 +368,7 @@ export default function Home() {
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">加载失败: {error}</p>
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -373,7 +376,7 @@ export default function Home() {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)]">加载中...</p>
|
||||
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -384,10 +387,10 @@ export default function Home() {
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
🐾 OpenClaw Bot Dashboard
|
||||
{t("home.title")}
|
||||
</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {data.agents.length} 个机器人 · 默认模型: {data.defaults.model}
|
||||
{t("models.totalPrefix")} {data.agents.length} {t("home.agentCount")} · {t("home.defaultModel")}: {data.defaults.model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -400,7 +403,7 @@ export default function Home() {
|
||||
>
|
||||
{REFRESH_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.value === 0 ? "🔄 手动刷新" : `⏱️ ${opt.label}`}
|
||||
{opt.value === 0 ? `🔄 ${opt.label}` : `⏱️ ${opt.label}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -416,22 +419,29 @@ export default function Home() {
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
更新于 {lastUpdated}
|
||||
{t("home.updatedAt")} {lastUpdated}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href="/models"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--bg)] text-sm font-medium hover:opacity-90 transition"
|
||||
>
|
||||
查看模型列表 →
|
||||
{t("home.viewModels")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/skills"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{t("home.skillMgmt")}
|
||||
</Link>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卡片墙 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} />
|
||||
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -439,7 +449,7 @@ export default function Home() {
|
||||
{allStats && (
|
||||
<div className="mt-8 p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">📊 全局统计趋势</h2>
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">{t("home.globalTrend")}</h2>
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||
<button key={r} onClick={() => setStatsRange(r)}
|
||||
@@ -457,21 +467,21 @@ export default function Home() {
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总 Input Token</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)]">{t("home.totalInputToken")}</div>
|
||||
<div className="text-lg font-bold text-blue-400">{formatTokens(totalInput)}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总 Output Token</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)]">{t("home.totalOutputToken")}</div>
|
||||
<div className="text-lg font-bold text-emerald-400">{formatTokens(totalOutput)}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">总消息数</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)]">{t("home.totalMessages")}</div>
|
||||
<div className="text-lg font-bold text-purple-400">{totalMsgs}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-[var(--text-muted)]">🔢 Token 消耗趋势</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{t("home.tokenTrend")}</span>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500 inline-block" /> Input</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" /> Output</span>
|
||||
@@ -480,12 +490,12 @@ export default function Home() {
|
||||
<TrendChart data={currentData} lines={[
|
||||
{ key: "inputTokens", color: "#3b82f6", label: "Input" },
|
||||
{ key: "outputTokens", color: "#10b981", label: "Output" },
|
||||
]} />
|
||||
]} t={t} />
|
||||
</div>
|
||||
{statsRange === "daily" && (
|
||||
<div>
|
||||
<span className="text-xs text-[var(--text-muted)]">⏱️ 平均响应时间趋势</span>
|
||||
<ResponseTrendChart data={currentData} />
|
||||
<span className="text-xs text-[var(--text-muted)]">{t("home.avgResponseTrend")}</span>
|
||||
<ResponseTrendChart data={currentData} t={t} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -498,7 +508,7 @@ export default function Home() {
|
||||
{data.groupChats && data.groupChats.length > 0 && (
|
||||
<div className="mt-8 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-3">
|
||||
💬 群聊拓扑
|
||||
{t("home.groupTopology")}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{data.groupChats.map((group, i) => (
|
||||
@@ -506,7 +516,7 @@ export default function Home() {
|
||||
<span className="text-lg">{group.channel === "feishu" ? "📱" : "🎮"}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">
|
||||
{group.channel === "feishu" ? "飞书群" : "Discord 频道"} · {group.groupId.split(":")[1]}
|
||||
{group.channel === "feishu" ? t("home.feishuGroup") : t("home.discordChannel")} · {group.groupId.split(":")[1]}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.agents.map((a) => (
|
||||
@@ -516,7 +526,7 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-muted)]">{group.agents.length} 个机器人</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">{group.agents.length} {t("home.bots")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -527,7 +537,7 @@ export default function Home() {
|
||||
{data.defaults.fallbacks.length > 0 && (
|
||||
<div className="mt-8 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-2">
|
||||
🔄 Fallback 模型
|
||||
{t("home.fallbackModels")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.defaults.fallbacks.map((f, i) => (
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return <I18nProvider>{children}</I18nProvider>;
|
||||
}
|
||||
+45
-33
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
|
||||
|
||||
interface Session {
|
||||
key: string;
|
||||
@@ -20,28 +21,16 @@ interface GatewayInfo {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
|
||||
main: { label: "主会话", emoji: "🏠", color: "bg-green-500/20 text-green-300 border-green-500/30" },
|
||||
"feishu-dm": { label: "飞书私聊", emoji: "📱", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
"feishu-group": { label: "飞书群聊", emoji: "👥", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
"discord-dm": { label: "Discord 私聊", emoji: "🎮", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
"discord-channel": { label: "Discord 频道", emoji: "📢", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
cron: { label: "定时任务", emoji: "⏰", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30" },
|
||||
unknown: { label: "未知", emoji: "❓", color: "bg-gray-500/20 text-gray-300 border-gray-500/30" },
|
||||
const TYPE_EMOJI_COLOR: Record<string, { emoji: string; color: string }> = {
|
||||
main: { emoji: "🏠", color: "bg-green-500/20 text-green-300 border-green-500/30" },
|
||||
"feishu-dm": { emoji: "📱", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
"feishu-group": { emoji: "👥", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
"discord-dm": { emoji: "🎮", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
"discord-channel": { emoji: "📢", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
cron: { emoji: "⏰", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30" },
|
||||
unknown: { emoji: "❓", color: "bg-gray-500/20 text-gray-300 border-gray-500/30" },
|
||||
};
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
if (!ts) return "-";
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return "-";
|
||||
return new Date(ts).toLocaleString("zh-CN");
|
||||
@@ -54,10 +43,29 @@ export default function SessionsPage() {
|
||||
const [gateway, setGateway] = useState<GatewayInfo>({ port: 18789 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useI18n();
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
if (!ts) return "-";
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t("common.justNow");
|
||||
if (mins < 60) return `${mins} ${t("common.minutesAgo")}`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} ${t("common.hoursAgo")}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} ${t("common.daysAgo")}`;
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): { label: string; emoji: string; color: string } {
|
||||
const info = TYPE_EMOJI_COLOR[type] || TYPE_EMOJI_COLOR.unknown;
|
||||
const labelKey = `sessions.type.${type}` as const;
|
||||
const label = t(TYPE_EMOJI_COLOR[type] ? labelKey : "sessions.type.unknown");
|
||||
return { ...info, label };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
// 并行获取 sessions 和 gateway 配置
|
||||
Promise.all([
|
||||
fetch(`/api/sessions/${agentId}`).then((r) => r.json()),
|
||||
fetch("/api/config").then((r) => r.json()),
|
||||
@@ -74,7 +82,7 @@ export default function SessionsPage() {
|
||||
if (!agentId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">缺少 agent 参数</p>
|
||||
<p className="text-red-400">{t("sessions.missingAgent")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +90,7 @@ export default function SessionsPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)]">加载中...</p>
|
||||
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +98,7 @@ export default function SessionsPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">加载失败: {error}</p>
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,28 +109,32 @@ export default function SessionsPage() {
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">📋 {agentId} 的会话列表</h1>
|
||||
<h1 className="text-2xl font-bold">📋 {agentId} {t("sessions.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {sessions.length} 个会话 · 总 Token: {(totalTokens / 1000).toFixed(1)}k
|
||||
{sessions.length} {t("sessions.sessionCount")} · {t("sessions.totalToken")}: {(totalTokens / 1000).toFixed(1)}k
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{t("common.backHome")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sessions.map((s) => {
|
||||
const typeInfo = TYPE_LABELS[s.type] || TYPE_LABELS.unknown;
|
||||
const typeInfo = getTypeLabel(s.type);
|
||||
let chatUrl = `http://localhost:${gateway.port}/chat?session=${encodeURIComponent(s.key)}`;
|
||||
if (gateway.token) chatUrl += `&token=${encodeURIComponent(gateway.token)}`;
|
||||
return (
|
||||
<div
|
||||
key={s.key}
|
||||
onClick={() => window.open(chatUrl, "_blank")}
|
||||
title={t("agent.openChat")}
|
||||
className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--accent)] transition cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string;
|
||||
usedBy: string[];
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useI18n();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [agents, setAgents] = useState<Record<string, AgentInfo>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/skills")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) setError(data.error);
|
||||
else {
|
||||
setSkills(data.skills);
|
||||
setAgents(data.agents);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const filtered = skills.filter((s) => {
|
||||
if (filter === "builtin" && s.source !== "builtin") return false;
|
||||
if (filter === "extension" && !s.source.startsWith("extension:")) return false;
|
||||
if (filter === "custom" && s.source !== "custom") return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const sourceLabel = (s: string) => {
|
||||
if (s === "builtin") return t("skills.source.builtin");
|
||||
if (s.startsWith("extension:")) return s.replace("extension:", `${t("skills.extension")}:`);
|
||||
return t("skills.source.custom");
|
||||
};
|
||||
|
||||
const sourceBadgeClass = (s: string) => {
|
||||
if (s === "builtin") return "bg-blue-500/20 text-blue-400";
|
||||
if (s.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
|
||||
return "bg-green-500/20 text-green-400";
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const builtinCount = skills.filter((s) => s.source === "builtin").length;
|
||||
const extCount = skills.filter((s) => s.source.startsWith("extension:")).length;
|
||||
const customCount = skills.filter((s) => s.source === "custom").length;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {skills.length} {t("skills.count")}({t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extCount} / {t("skills.custom")} {customCount})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{t("common.backOverview")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(["all", "builtin", "extension", "custom"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
|
||||
filter === f
|
||||
? "bg-[var(--accent)] text-[var(--bg)]"
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? t("skills.all") : f === "builtin" ? t("skills.builtin") : f === "extension" ? t("skills.extension") : t("skills.custom")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("skills.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-64"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("skills.showing")} {filtered.length} {t("skills.unit")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skills Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((skill) => (
|
||||
<div
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{skill.emoji}</span>
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${sourceBadgeClass(skill.source)}`}>
|
||||
{sourceLabel(skill.source)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
|
||||
{skill.description || t("skills.noDesc")}
|
||||
</p>
|
||||
{skill.usedBy.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.usedBy.map((agentId) => {
|
||||
const agent = agents[agentId];
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
|
||||
>
|
||||
{agent?.emoji || "🤖"} {agent?.name || agentId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+35
-35
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useI18n, LanguageSwitcher } from "@/lib/i18n";
|
||||
|
||||
interface DayStat {
|
||||
date: string;
|
||||
@@ -22,12 +23,6 @@ interface StatsData {
|
||||
|
||||
type TimeRange = "daily" | "weekly" | "monthly";
|
||||
|
||||
const RANGE_LABELS: Record<TimeRange, string> = {
|
||||
daily: "按天",
|
||||
weekly: "按周",
|
||||
monthly: "按月",
|
||||
};
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
||||
@@ -46,16 +41,18 @@ function BarChart({
|
||||
labelKey,
|
||||
bars,
|
||||
height = 220,
|
||||
noDataText,
|
||||
}: {
|
||||
data: DayStat[];
|
||||
labelKey: "date";
|
||||
bars: { key: keyof DayStat; color: string; label: string }[];
|
||||
height?: number;
|
||||
noDataText: string;
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-[var(--text-muted)] text-sm">
|
||||
暂无数据
|
||||
{noDataText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +62,6 @@ function BarChart({
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
// Find max value across all bars
|
||||
let maxVal = 0;
|
||||
for (const d of data) {
|
||||
for (const b of bars) {
|
||||
@@ -75,17 +71,14 @@ function BarChart({
|
||||
}
|
||||
if (maxVal === 0) maxVal = 1;
|
||||
|
||||
// Y-axis ticks
|
||||
const tickCount = 4;
|
||||
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => Math.round((maxVal / tickCount) * i));
|
||||
|
||||
const groupWidth = chartW / data.length;
|
||||
const barWidth = Math.min(20, (groupWidth - 8) / bars.length);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={width} height={height} className="text-[var(--text-muted)]">
|
||||
{/* Y-axis grid + labels */}
|
||||
{ticks.map((tick, i) => {
|
||||
const y = padding.top + chartH - (tick / maxVal) * chartH;
|
||||
return (
|
||||
@@ -97,8 +90,6 @@ function BarChart({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const groupX = padding.left + i * groupWidth;
|
||||
return (
|
||||
@@ -116,7 +107,6 @@ function BarChart({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* X-axis label */}
|
||||
<text
|
||||
x={groupX + groupWidth / 2}
|
||||
y={height - padding.bottom + 16}
|
||||
@@ -130,8 +120,6 @@ function BarChart({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Axes */}
|
||||
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + chartH} stroke="currentColor" opacity={0.3} />
|
||||
<line x1={padding.left} y1={padding.top + chartH} x2={width - padding.right} y2={padding.top + chartH} stroke="currentColor" opacity={0.3} />
|
||||
</svg>
|
||||
@@ -140,12 +128,12 @@ function BarChart({
|
||||
}
|
||||
|
||||
// Response time chart (separate scale)
|
||||
function ResponseTimeChart({ data, height = 220 }: { data: DayStat[]; height?: number }) {
|
||||
function ResponseTimeChart({ data, height = 220, noDataText }: { data: DayStat[]; height?: number; noDataText: string }) {
|
||||
const filtered = data.filter((d) => d.avgResponseMs > 0);
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-[var(--text-muted)] text-sm">
|
||||
暂无响应时间数据
|
||||
{noDataText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,6 +200,16 @@ export default function StatsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [range, setRange] = useState<TimeRange>("daily");
|
||||
const { t } = useI18n();
|
||||
|
||||
const getRangeLabel = (r: TimeRange): string => {
|
||||
const labels: Record<TimeRange, string> = {
|
||||
daily: t("range.daily"),
|
||||
weekly: t("range.weekly"),
|
||||
monthly: t("range.monthly"),
|
||||
};
|
||||
return labels[r];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
@@ -228,7 +226,7 @@ export default function StatsPage() {
|
||||
if (!agentId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">缺少 agent 参数</p>
|
||||
<p className="text-red-400">{t("stats.missingAgent")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +234,7 @@ export default function StatsPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)]">加载中...</p>
|
||||
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -244,7 +242,7 @@ export default function StatsPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-red-400">加载失败: {error}</p>
|
||||
<p className="text-red-400">{t("common.loadError")}: {error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -261,15 +259,15 @@ export default function StatsPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">📊 {agentId} 消息统计</h1>
|
||||
<h1 className="text-2xl font-bold">{`📊 ${agentId} ${t("stats.title")}`}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
Token 消耗与响应时间分析
|
||||
{t("stats.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Time range selector */}
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||
{(["daily", "weekly", "monthly"] as TimeRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
@@ -279,7 +277,7 @@ export default function StatsPage() {
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{RANGE_LABELS[r]}
|
||||
{getRangeLabel(r)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -287,41 +285,42 @@ export default function StatsPage() {
|
||||
href={`/sessions?agent=${agentId}`}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
📋 会话列表
|
||||
{t("stats.sessionList")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
← 首页
|
||||
{t("stats.home")}
|
||||
</Link>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">总 Input Token</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalInputToken")}</div>
|
||||
<div className="text-xl font-bold text-blue-400">{formatTokens(totalInput)}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">总 Output Token</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalOutputToken")}</div>
|
||||
<div className="text-xl font-bold text-emerald-400">{formatTokens(totalOutput)}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">总消息数</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalMessages")}</div>
|
||||
<div className="text-xl font-bold text-purple-400">{totalMessages}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">数据周期</div>
|
||||
<div className="text-xl font-bold text-[var(--text)]">{currentData.length} {RANGE_LABELS[range].slice(1)}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.dataPeriod")}</div>
|
||||
<div className="text-xl font-bold text-[var(--text)]">{currentData.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token chart */}
|
||||
<div className="p-5 rounded-xl border border-[var(--border)] bg-[var(--card)] mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">🔢 Token 消耗</h2>
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">{t("stats.tokenConsumption")}</h2>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-blue-500 inline-block" /> Input</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-emerald-500 inline-block" /> Output</span>
|
||||
@@ -334,14 +333,15 @@ export default function StatsPage() {
|
||||
{ key: "inputTokens", color: "#3b82f6", label: "Input" },
|
||||
{ key: "outputTokens", color: "#10b981", label: "Output" },
|
||||
]}
|
||||
noDataText={t("common.noData")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Response time chart */}
|
||||
{range === "daily" && (
|
||||
<div className="p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">⏱️ 平均响应时间</h2>
|
||||
<ResponseTimeChart data={currentData} />
|
||||
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">{t("stats.avgResponseTime")}</h2>
|
||||
<ResponseTimeChart data={currentData} noDataText={t("stats.noResponseData")} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const translations: Record<Locale, Record<string, string>> = {
|
||||
zh: {
|
||||
// layout
|
||||
"site.title": "OpenClaw Bot Dashboard",
|
||||
"site.desc": "查看所有 OpenClaw 机器人配置",
|
||||
|
||||
// common
|
||||
"common.loading": "加载中...",
|
||||
"common.loadError": "加载失败",
|
||||
"common.backHome": "← 返回首页",
|
||||
"common.backOverview": "← 返回总览",
|
||||
"common.noData": "暂无数据",
|
||||
"common.test": "🧪 测试",
|
||||
"common.testing": "⏳ 测试中...",
|
||||
"common.justNow": "刚刚",
|
||||
"common.minutesAgo": "分钟前",
|
||||
"common.hoursAgo": "小时前",
|
||||
"common.daysAgo": "天前",
|
||||
"common.manualRefresh": "手动刷新",
|
||||
"common.seconds": "秒",
|
||||
"common.minute": "分钟",
|
||||
"common.minutes": "分钟",
|
||||
|
||||
// home page
|
||||
"home.title": "🐾 OpenClaw Bot Dashboard",
|
||||
"home.agentCount": "个机器人",
|
||||
"home.defaultModel": "默认模型",
|
||||
"home.viewModels": "查看模型列表 →",
|
||||
"home.skillMgmt": "🧩 技能管理",
|
||||
"home.updatedAt": "更新于",
|
||||
"home.refreshManual": "🔄 手动刷新",
|
||||
"home.globalTrend": "📊 全局统计趋势",
|
||||
"home.totalInputToken": "总 Input Token",
|
||||
"home.totalOutputToken": "总 Output Token",
|
||||
"home.totalMessages": "总消息数",
|
||||
"home.tokenTrend": "🔢 Token 消耗趋势",
|
||||
"home.avgResponseTrend": "⏱️ 平均响应时间趋势",
|
||||
"home.groupTopology": "💬 群聊拓扑",
|
||||
"home.fallbackModels": "🔄 Fallback 模型",
|
||||
"home.feishuGroup": "飞书群",
|
||||
"home.discordChannel": "Discord 频道",
|
||||
"home.bots": "个机器人",
|
||||
"home.noResponseData": "暂无响应时间数据",
|
||||
|
||||
// agent card
|
||||
"agent.model": "模型",
|
||||
"agent.platform": "平台",
|
||||
"agent.feishuAppId": "飞书 App ID",
|
||||
"agent.sessionCount": "会话数",
|
||||
"agent.tokenUsage": "Token 用量",
|
||||
"agent.stats": "统计",
|
||||
"agent.lastActive": "最近活跃",
|
||||
"agent.inUse": "使用中:",
|
||||
"agent.openChat": "点击打开聊天页面",
|
||||
|
||||
// platform
|
||||
"platform.feishu": "📱 飞书",
|
||||
"platform.discord": "🎮 Discord",
|
||||
|
||||
// time range
|
||||
"range.daily": "按天",
|
||||
"range.weekly": "按周",
|
||||
"range.monthly": "按月",
|
||||
|
||||
// refresh options
|
||||
"refresh.manual": "手动刷新",
|
||||
"refresh.10s": "10 秒",
|
||||
"refresh.30s": "30 秒",
|
||||
"refresh.1m": "1 分钟",
|
||||
"refresh.5m": "5 分钟",
|
||||
"refresh.10m": "10 分钟",
|
||||
|
||||
// models page
|
||||
"models.title": "OpenClaw接入模型列表",
|
||||
"models.providerCount": "个 Provider",
|
||||
"models.totalPrefix": "共",
|
||||
"models.testAll": "🧪 测试全部模型",
|
||||
"models.testingAll": "⏳ 测试中...",
|
||||
"models.colModelId": "模型 ID",
|
||||
"models.colName": "名称",
|
||||
"models.colContext": "上下文窗口",
|
||||
"models.colMaxOutput": "最大输出",
|
||||
"models.colInputType": "输入类型",
|
||||
"models.colReasoning": "推理",
|
||||
"models.colInputToken": "Input Token",
|
||||
"models.colOutputToken": "Output Token",
|
||||
"models.colAvgResponse": "平均响应",
|
||||
"models.colTest": "测试",
|
||||
"models.noExplicitModels": "无显式模型定义(通过 provider 名称推断)",
|
||||
|
||||
// stats page
|
||||
"stats.title": "消息统计",
|
||||
"stats.subtitle": "Token 消耗与响应时间分析",
|
||||
"stats.totalInputToken": "总 Input Token",
|
||||
"stats.totalOutputToken": "总 Output Token",
|
||||
"stats.totalMessages": "总消息数",
|
||||
"stats.dataPeriod": "数据周期",
|
||||
"stats.tokenConsumption": "🔢 Token 消耗",
|
||||
"stats.avgResponseTime": "⏱️ 平均响应时间",
|
||||
"stats.sessionList": "📋 会话列表",
|
||||
"stats.home": "← 首页",
|
||||
"stats.missingAgent": "缺少 agent 参数",
|
||||
"stats.noResponseData": "暂无响应时间数据",
|
||||
|
||||
// sessions page
|
||||
"sessions.title": "的会话列表",
|
||||
"sessions.sessionCount": "个会话",
|
||||
"sessions.totalToken": "总 Token",
|
||||
"sessions.missingAgent": "缺少 agent 参数",
|
||||
"sessions.type.main": "主会话",
|
||||
"sessions.type.feishu-dm": "飞书私聊",
|
||||
"sessions.type.feishu-group": "飞书群聊",
|
||||
"sessions.type.discord-dm": "Discord 私聊",
|
||||
"sessions.type.discord-channel": "Discord 频道",
|
||||
"sessions.type.cron": "定时任务",
|
||||
"sessions.type.unknown": "未知",
|
||||
|
||||
// skills page
|
||||
"skills.title": "🧩 技能管理",
|
||||
"skills.count": "个技能",
|
||||
"skills.builtin": "内置",
|
||||
"skills.extension": "扩展",
|
||||
"skills.custom": "自定义",
|
||||
"skills.all": "全部",
|
||||
"skills.search": "搜索技能...",
|
||||
"skills.showing": "显示",
|
||||
"skills.unit": "个",
|
||||
"skills.noDesc": "无描述",
|
||||
"skills.source.builtin": "内置",
|
||||
"skills.source.custom": "自定义",
|
||||
},
|
||||
en: {
|
||||
// layout
|
||||
"site.title": "OpenClaw Bot Dashboard",
|
||||
"site.desc": "View all OpenClaw bot configurations",
|
||||
|
||||
// common
|
||||
"common.loading": "Loading...",
|
||||
"common.loadError": "Failed to load",
|
||||
"common.backHome": "← Back to Home",
|
||||
"common.backOverview": "← Back to Overview",
|
||||
"common.noData": "No data",
|
||||
"common.test": "🧪 Test",
|
||||
"common.testing": "⏳ Testing...",
|
||||
"common.justNow": "just now",
|
||||
"common.minutesAgo": "min ago",
|
||||
"common.hoursAgo": "hours ago",
|
||||
"common.daysAgo": "days ago",
|
||||
"common.manualRefresh": "Manual Refresh",
|
||||
"common.seconds": "seconds",
|
||||
"common.minute": "minute",
|
||||
"common.minutes": "minutes",
|
||||
|
||||
// home page
|
||||
"home.title": "🐾 OpenClaw Bot Dashboard",
|
||||
"home.agentCount": "bots",
|
||||
"home.defaultModel": "Default model",
|
||||
"home.viewModels": "View Models →",
|
||||
"home.skillMgmt": "🧩 Skills",
|
||||
"home.updatedAt": "Updated at",
|
||||
"home.refreshManual": "🔄 Manual Refresh",
|
||||
"home.globalTrend": "📊 Global Statistics",
|
||||
"home.totalInputToken": "Total Input Token",
|
||||
"home.totalOutputToken": "Total Output Token",
|
||||
"home.totalMessages": "Total Messages",
|
||||
"home.tokenTrend": "🔢 Token Usage Trend",
|
||||
"home.avgResponseTrend": "⏱️ Avg Response Time Trend",
|
||||
"home.groupTopology": "💬 Group Chat Topology",
|
||||
"home.fallbackModels": "🔄 Fallback Models",
|
||||
"home.feishuGroup": "Feishu Group",
|
||||
"home.discordChannel": "Discord Channel",
|
||||
"home.bots": "bots",
|
||||
"home.noResponseData": "No response time data",
|
||||
|
||||
// agent card
|
||||
"agent.model": "Model",
|
||||
"agent.platform": "Platform",
|
||||
"agent.feishuAppId": "Feishu App ID",
|
||||
"agent.sessionCount": "Sessions",
|
||||
"agent.tokenUsage": "Token Usage",
|
||||
"agent.stats": "Stats",
|
||||
"agent.lastActive": "Last Active",
|
||||
"agent.inUse": "Used by:",
|
||||
"agent.openChat": "Click to open chat",
|
||||
|
||||
// platform
|
||||
"platform.feishu": "📱 Feishu",
|
||||
"platform.discord": "🎮 Discord",
|
||||
|
||||
// time range
|
||||
"range.daily": "Daily",
|
||||
"range.weekly": "Weekly",
|
||||
"range.monthly": "Monthly",
|
||||
|
||||
// refresh options
|
||||
"refresh.manual": "Manual Refresh",
|
||||
"refresh.10s": "10s",
|
||||
"refresh.30s": "30s",
|
||||
"refresh.1m": "1 min",
|
||||
"refresh.5m": "5 min",
|
||||
"refresh.10m": "10 min",
|
||||
|
||||
// models page
|
||||
"models.title": "OpenClaw Model List",
|
||||
"models.providerCount": "Providers",
|
||||
"models.totalPrefix": "",
|
||||
"models.testAll": "🧪 Test All Models",
|
||||
"models.testingAll": "⏳ Testing...",
|
||||
"models.colModelId": "Model ID",
|
||||
"models.colName": "Name",
|
||||
"models.colContext": "Context Window",
|
||||
"models.colMaxOutput": "Max Output",
|
||||
"models.colInputType": "Input Type",
|
||||
"models.colReasoning": "Reasoning",
|
||||
"models.colInputToken": "Input Token",
|
||||
"models.colOutputToken": "Output Token",
|
||||
"models.colAvgResponse": "Avg Response",
|
||||
"models.colTest": "Test",
|
||||
"models.noExplicitModels": "No explicit model definitions (inferred from provider name)",
|
||||
|
||||
// stats page
|
||||
"stats.title": "Message Statistics",
|
||||
"stats.subtitle": "Token usage and response time analysis",
|
||||
"stats.totalInputToken": "Total Input Token",
|
||||
"stats.totalOutputToken": "Total Output Token",
|
||||
"stats.totalMessages": "Total Messages",
|
||||
"stats.dataPeriod": "Data Period",
|
||||
"stats.tokenConsumption": "🔢 Token Usage",
|
||||
"stats.avgResponseTime": "⏱️ Avg Response Time",
|
||||
"stats.sessionList": "📋 Sessions",
|
||||
"stats.home": "← Home",
|
||||
"stats.missingAgent": "Missing agent parameter",
|
||||
"stats.noResponseData": "No response time data",
|
||||
|
||||
// sessions page
|
||||
"sessions.title": "Sessions",
|
||||
"sessions.sessionCount": "sessions",
|
||||
"sessions.totalToken": "Total Token",
|
||||
"sessions.missingAgent": "Missing agent parameter",
|
||||
"sessions.type.main": "Main",
|
||||
"sessions.type.feishu-dm": "Feishu DM",
|
||||
"sessions.type.feishu-group": "Feishu Group",
|
||||
"sessions.type.discord-dm": "Discord DM",
|
||||
"sessions.type.discord-channel": "Discord Channel",
|
||||
"sessions.type.cron": "Cron Job",
|
||||
"sessions.type.unknown": "Unknown",
|
||||
|
||||
// skills page
|
||||
"skills.title": "🧩 Skill Management",
|
||||
"skills.count": "skills",
|
||||
"skills.builtin": "Built-in",
|
||||
"skills.extension": "Extension",
|
||||
"skills.custom": "Custom",
|
||||
"skills.all": "All",
|
||||
"skills.search": "Search skills...",
|
||||
"skills.showing": "Showing",
|
||||
"skills.unit": "",
|
||||
"skills.noDesc": "No description",
|
||||
"skills.source.builtin": "Built-in",
|
||||
"skills.source.custom": "Custom",
|
||||
},
|
||||
};
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType>({
|
||||
locale: "zh",
|
||||
setLocale: () => {},
|
||||
t: (key) => key,
|
||||
});
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (localStorage.getItem("locale") as Locale) || "zh";
|
||||
}
|
||||
return "zh";
|
||||
});
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("locale", l);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string) => translations[locale]?.[key] ?? translations.zh[key] ?? key,
|
||||
[locale]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLocale(locale === "zh" ? "en" : "zh")}
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--card)] border border-[var(--border)] text-xs font-medium hover:border-[var(--accent)] transition cursor-pointer"
|
||||
title={locale === "zh" ? "Switch to English" : "切换到中文"}
|
||||
>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user