feat: API analytics dashboard with i18n and theme support
Next.js full-stack analytics dashboard for new-api. - Direct PostgreSQL readonly queries on logs table - 5 pages: Dashboard, Rankings, Aggregation, Logs, Detail - Dark/Light/System theme with CSS variables - Chinese/English i18n (default Chinese) - Recharts with dual Y-axis for input/output tokens - Lucide icons + Motion animations - Docker + docker-compose with external sinobridge network, port 8019
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.md
|
||||
.playwright-mcp
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
PG_CONNECTION_STRING=postgresql://postgres:EUWqvk39FRHeoP8l262icXvi0ktrkejECUUSxdmKozrSeqpGyPSXTSmNTaTc4KD0@192.168.111.90:5433/new-api
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.local
|
||||
!.env.production
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN npm install --frozen-lockfile
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Production ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8019
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 8019
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
140
app/aggregation/page.tsx
Normal file
140
app/aggregation/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface AggItem {
|
||||
rank: number; name: string; calls: number;
|
||||
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
||||
}
|
||||
|
||||
type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens";
|
||||
|
||||
export default function AggregationPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [data, setData] = useState<AggItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("total_tokens");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const res = await fetch(buildQuery("/api/aggregation", { start, end }));
|
||||
setData(await res.json());
|
||||
setLoading(false);
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
const diff = (a[sortKey] as number) - (b[sortKey] as number);
|
||||
return sortAsc ? diff : -diff;
|
||||
});
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens }),
|
||||
{ calls: 0, tokens: 0 }
|
||||
);
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortAsc(!sortAsc);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortKey }) {
|
||||
if (sortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.5 }} />;
|
||||
return sortAsc
|
||||
? <ArrowUp className="h-3 w-3" style={{ color: "var(--accent)" }} />
|
||||
: <ArrowDown className="h-3 w-3" style={{ color: "var(--accent)" }} />;
|
||||
}
|
||||
|
||||
const sortHeaders: { key: SortKey; label: string }[] = [
|
||||
{ key: "calls", label: t("th.calls") },
|
||||
{ key: "prompt_tokens", label: t("th.input") },
|
||||
{ key: "completion_tokens", label: t("th.output") },
|
||||
{ key: "total_tokens", label: t("th.totalToken") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h1 className="text-2xl font-bold gradient-text">{t("agg.title")}</h1>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{!loading && data.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="glass p-4 flex items-center gap-8 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.userCount")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{data.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalCalls")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatNumber(totals.calls)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.5 }} />
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("agg.totalToken")}</span>
|
||||
<span className="font-semibold font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(totals.tokens)}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>#</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.user")}</th>
|
||||
{sortHeaders.map(h => (
|
||||
<th key={h.key} className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider cursor-pointer select-none transition-colors"
|
||||
style={{ color: "var(--text-muted)" }} onClick={() => handleSort(h.key)}>
|
||||
<span className="inline-flex items-center gap-1">{h.label} <SortIcon col={h.key} /></span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
|
||||
) : sorted.map((item, i) => {
|
||||
const pct = totals.tokens > 0 ? (item.total_tokens / totals.tokens * 100) : 0;
|
||||
return (
|
||||
<tr key={item.name} className="row-glow transition-colors group" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{i + 1}</td>
|
||||
<td className="px-4 py-3 font-medium transition-colors" style={{ color: "var(--text-accent)" }}>{item.name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.prompt_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 rounded-full overflow-hidden" style={{ background: "var(--progress-bg)" }}>
|
||||
<motion.div className="h-full rounded-full bg-gradient-to-r from-[var(--accent)] to-[var(--accent-purple)]"
|
||||
initial={{ width: 0 }} animate={{ width: `${Math.min(pct, 100)}%` }}
|
||||
transition={{ duration: 0.8, delay: i * 0.02 }} />
|
||||
</div>
|
||||
<span className="text-xs font-[family-name:var(--font-geist-mono)] w-10 text-right" style={{ color: "var(--text-muted)" }}>{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/api/aggregation/route.ts
Normal file
12
app/api/aggregation/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getUserRanking } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
|
||||
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
|
||||
|
||||
// Get ALL users (no limit) for aggregation view
|
||||
const data = await getUserRanking(startTs, endTs, 500);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
29
app/api/detail/[type]/[id]/route.ts
Normal file
29
app/api/detail/[type]/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getUserDetail, getModelDetail, getChannelDetail } from "@/lib/queries";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ type: string; id: string }> }
|
||||
) {
|
||||
const { type, id } = await params;
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
|
||||
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
|
||||
|
||||
let data;
|
||||
switch (type) {
|
||||
case "user":
|
||||
data = await getUserDetail(decodeURIComponent(id), startTs, endTs);
|
||||
break;
|
||||
case "model":
|
||||
data = await getModelDetail(decodeURIComponent(id), startTs, endTs);
|
||||
break;
|
||||
case "channel":
|
||||
data = await getChannelDetail(Number(id), startTs, endTs);
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
19
app/api/logs/route.ts
Normal file
19
app/api/logs/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getLogs } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
|
||||
const data = await getLogs({
|
||||
page: sp.get("page") ? Number(sp.get("page")) : 1,
|
||||
pageSize: sp.get("page_size") ? Number(sp.get("page_size")) : 100,
|
||||
startTs: sp.get("start") ? Number(sp.get("start")) : undefined,
|
||||
endTs: sp.get("end") ? Number(sp.get("end")) : undefined,
|
||||
username: sp.get("username") || undefined,
|
||||
model: sp.get("model") || undefined,
|
||||
channelId: sp.get("channel_id") ? Number(sp.get("channel_id")) : undefined,
|
||||
tokenName: sp.get("token_name") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
11
app/api/overview/route.ts
Normal file
11
app/api/overview/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOverview } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
|
||||
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
|
||||
|
||||
const data = await getOverview(startTs, endTs);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
24
app/api/rankings/route.ts
Normal file
24
app/api/rankings/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getUserRanking, getModelRanking, getChannelRanking } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const type = sp.get("type") || "user";
|
||||
const startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
|
||||
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
|
||||
const limit = sp.get("limit") ? Number(sp.get("limit")) : 50;
|
||||
|
||||
let data;
|
||||
switch (type) {
|
||||
case "model":
|
||||
data = await getModelRanking(startTs, endTs, limit);
|
||||
break;
|
||||
case "channel":
|
||||
data = await getChannelRanking(startTs, endTs, limit);
|
||||
break;
|
||||
default:
|
||||
data = await getUserRanking(startTs, endTs, limit);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
12
app/api/trends/route.ts
Normal file
12
app/api/trends/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTrends } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const granularity = (sp.get("granularity") || "day") as "day" | "week" | "month";
|
||||
const startTs = sp.get("start") ? Number(sp.get("start")) : undefined;
|
||||
const endTs = sp.get("end") ? Number(sp.get("end")) : undefined;
|
||||
|
||||
const data = await getTrends(granularity, startTs, endTs);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
114
app/detail/[...slug]/page.tsx
Normal file
114
app/detail/[...slug]/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Hash, Zap, MessageSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { StatsCard } from "@/components/StatsCard";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { TrendChart } from "@/components/charts/TrendChart";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface DetailData {
|
||||
calls: number; prompt_tokens: number; completion_tokens: number;
|
||||
total_tokens: number; quota: number;
|
||||
models?: { name: string; calls: number; total_tokens: number; quota: number }[];
|
||||
users?: { name: string; calls: number; total_tokens: number; quota: number }[];
|
||||
channel_name?: string;
|
||||
}
|
||||
|
||||
export default function DetailPage() {
|
||||
const { t } = useI18n();
|
||||
const params = useParams();
|
||||
const segments = Array.isArray(params.slug) ? params.slug : [];
|
||||
const type = segments[0] || "";
|
||||
const id = segments[1] || "";
|
||||
const decodedId = decodeURIComponent(id);
|
||||
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [trends, setTrends] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const tp = { start, end };
|
||||
const [detail, tr] = await Promise.all([
|
||||
fetch(buildQuery(`/api/detail/${type}/${encodeURIComponent(decodedId)}`, tp)).then(r => r.json()),
|
||||
fetch(buildQuery("/api/trends", { ...tp, granularity: "day",
|
||||
...(type === "user" ? { username: decodedId } : {}),
|
||||
...(type === "model" ? { model: decodedId } : {}),
|
||||
...(type === "channel" ? { channel_id: decodedId } : {}),
|
||||
})).then(r => r.json()),
|
||||
]);
|
||||
setData(detail); setTrends(tr); setLoading(false);
|
||||
}, [range, type, decodedId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const title = type === "channel" ? (data?.channel_name || decodedId) : decodedId;
|
||||
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
|
||||
const breakdownItems = data?.models || data?.users || [];
|
||||
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<Link href="/rankings" className="inline-flex items-center gap-1 text-xs transition-colors mb-2" style={{ color: "var(--text-muted)" }}>
|
||||
<ArrowLeft className="h-3 w-3" /> {t("common.backToRankings")}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wider px-2 py-0.5 rounded" style={{ color: "var(--text-muted)", background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}>{typeLabel}</span>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>{title}</h1>
|
||||
</div>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center"><div className="h-6 w-6 animate-spin rounded-full spinner" /></div>
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<StatsCard title={t("dash.totalCalls")} value={data.calls} icon={Hash} delay={0} />
|
||||
<StatsCard title={t("th.totalToken")} value={data.total_tokens} format="tokens" icon={Zap} delay={0.05} />
|
||||
<StatsCard title={t("th.input")} value={data.prompt_tokens} format="tokens" icon={MessageSquare} delay={0.1} />
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-5">
|
||||
<h2 className="mb-4 text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{t("detail.trend")}</h2>
|
||||
<TrendChart data={trends} />
|
||||
</motion.div>
|
||||
|
||||
{breakdownItems.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass overflow-hidden">
|
||||
<h2 className="px-5 pt-5 text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{breakdownLabel}</h2>
|
||||
<table className="w-full text-sm mt-3">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdownItems.map((item) => (
|
||||
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
196
app/globals.css
Normal file
196
app/globals.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
/* ═══ DARK THEME ═══ */
|
||||
:root, [data-theme="dark"] {
|
||||
--background: #06080d;
|
||||
--foreground: #c8d6e5;
|
||||
--accent: #00e5ff;
|
||||
--accent-dim: #0097a7;
|
||||
--accent-purple: #7c4dff;
|
||||
--accent-pink: #ff4081;
|
||||
--surface: rgba(12, 18, 30, 0.7);
|
||||
--surface-border: rgba(0, 229, 255, 0.08);
|
||||
--surface-hover: rgba(0, 229, 255, 0.04);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c8d6e5;
|
||||
--text-muted: #6b7280;
|
||||
--text-accent: #00e5ff;
|
||||
--text-accent-hover: #67edff;
|
||||
--glow-cyan: 0 0 20px rgba(0, 229, 255, 0.15);
|
||||
--grid-color: rgba(0, 229, 255, 0.03);
|
||||
--row-hover: rgba(0, 229, 255, 0.04);
|
||||
--input-bg: rgba(12, 18, 30, 0.6);
|
||||
--input-border: rgba(0, 229, 255, 0.1);
|
||||
--input-focus: rgba(0, 229, 255, 0.4);
|
||||
--chart-grid: rgba(0, 229, 255, 0.06);
|
||||
--btn-active-bg: rgba(0, 229, 255, 0.2);
|
||||
--btn-active-border: rgba(0, 229, 255, 0.5);
|
||||
--progress-bg: rgba(0, 229, 255, 0.08);
|
||||
--spinner-track: rgba(0, 229, 255, 0.3);
|
||||
--spinner-head: #00e5ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ═══ LIGHT THEME ═══ */
|
||||
[data-theme="light"] {
|
||||
--background: #f7f8fc;
|
||||
--foreground: #1e293b;
|
||||
--accent: #0284c7;
|
||||
--accent-dim: #0369a1;
|
||||
--accent-purple: #7c3aed;
|
||||
--accent-pink: #db2777;
|
||||
--surface: rgba(255, 255, 255, 0.85);
|
||||
--surface-border: rgba(2, 132, 199, 0.12);
|
||||
--surface-hover: rgba(2, 132, 199, 0.04);
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--text-accent: #0284c7;
|
||||
--text-accent-hover: #0369a1;
|
||||
--glow-cyan: 0 4px 20px rgba(2, 132, 199, 0.08);
|
||||
--grid-color: rgba(2, 132, 199, 0.04);
|
||||
--row-hover: rgba(2, 132, 199, 0.05);
|
||||
--input-bg: rgba(255, 255, 255, 0.8);
|
||||
--input-border: rgba(2, 132, 199, 0.15);
|
||||
--input-focus: rgba(2, 132, 199, 0.4);
|
||||
--chart-grid: rgba(2, 132, 199, 0.08);
|
||||
--btn-active-bg: rgba(2, 132, 199, 0.12);
|
||||
--btn-active-border: rgba(2, 132, 199, 0.3);
|
||||
--progress-bg: rgba(2, 132, 199, 0.1);
|
||||
--spinner-track: rgba(2, 132, 199, 0.3);
|
||||
--spinner-head: #0284c7;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Grid background — dark only */
|
||||
[data-theme="dark"] body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 20%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Top ambient glow — dark only */
|
||||
[data-theme="dark"] body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -200px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse, rgba(0, 229, 255, 0.06) 0%, transparent 70%);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Light theme subtle background */
|
||||
[data-theme="light"] body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(2, 132, 199, 0.03) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* Glass card */
|
||||
.glass {
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.2s, background 0.3s;
|
||||
}
|
||||
.glass:hover {
|
||||
border-color: var(--input-border);
|
||||
}
|
||||
|
||||
/* Gradient heading */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-purple) 50%, var(--accent-pink) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* Table row glow on hover */
|
||||
.row-glow:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
/* Accent button */
|
||||
.btn-accent {
|
||||
background: var(--btn-active-bg);
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-accent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-accent:hover {
|
||||
border-color: var(--btn-active-border);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
/* Input style */
|
||||
.input-glass {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--foreground);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input-glass::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.input-glass:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-focus);
|
||||
box-shadow: 0 0 12px rgba(2, 132, 199, 0.1);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
border: 2px solid var(--spinner-track);
|
||||
border-top-color: var(--spinner-head);
|
||||
}
|
||||
|
||||
/* Themed text utility classes */
|
||||
.text-t-primary { color: var(--text-primary); }
|
||||
.text-t-secondary { color: var(--text-secondary); }
|
||||
.text-t-muted { color: var(--text-muted); }
|
||||
.text-t-accent { color: var(--text-accent); }
|
||||
.text-t-accent:hover { color: var(--text-accent-hover); }
|
||||
.border-t { border-color: var(--surface-border); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--surface-border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--input-border); }
|
||||
|
||||
/* Recharts overrides */
|
||||
.recharts-cartesian-grid-horizontal line,
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: var(--chart-grid) !important;
|
||||
}
|
||||
.recharts-text {
|
||||
fill: var(--text-muted) !important;
|
||||
}
|
||||
45
app/layout.tsx
Normal file
45
app/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Outfit, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ClientProviders } from "@/components/ClientProviders";
|
||||
|
||||
const outfit = Outfit({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700", "800"],
|
||||
});
|
||||
|
||||
const jetbrains = JetBrains_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "API Analytics — Neural Pulse",
|
||||
description: "Real-time API usage analytics dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh" className={`${outfit.variable} ${jetbrains.variable} h-full antialiased dark`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
(function(){
|
||||
var t=localStorage.getItem('theme')||'system';
|
||||
var r=t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t;
|
||||
document.documentElement.classList.add(r);
|
||||
document.documentElement.setAttribute('data-theme',r);
|
||||
})();
|
||||
`}} />
|
||||
</head>
|
||||
<body className="min-h-full">
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
113
app/logs/page.tsx
Normal file
113
app/logs/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface LogEntry {
|
||||
id: number; created_at: string; display_name: string;
|
||||
real_model: string; channel_name: string; prompt_tokens: number;
|
||||
completion_tokens: number; total_tokens: number;
|
||||
use_time: number; is_stream: boolean;
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("7d");
|
||||
const [page, setPage] = useState(1);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({ username: "", model: "", token_name: "" });
|
||||
const pageSize = 100;
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
|
||||
const data = await res.json();
|
||||
setLogs(data.logs); setTotal(data.total); setLoading(false);
|
||||
}, [range, page, filters]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
useEffect(() => { setPage(1); }, [range, filters]);
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="flex items-center gap-3">
|
||||
<ScrollText className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h1 className="text-2xl font-bold gradient-text">{t("logs.title")}</h1>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
<input type="text" placeholder={t("logs.filterUser")} value={filters.username}
|
||||
onChange={(e) => setFilters({ ...filters, username: e.target.value })}
|
||||
className="input-glass rounded-lg pl-9 pr-3 py-2 text-sm w-36" />
|
||||
</div>
|
||||
<input type="text" placeholder={t("logs.filterModel")} value={filters.model}
|
||||
onChange={(e) => setFilters({ ...filters, model: e.target.value })}
|
||||
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
|
||||
<input type="text" placeholder={t("logs.filterToken")} value={filters.token_name}
|
||||
onChange={(e) => setFilters({ ...filters, token_name: e.target.value })}
|
||||
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
|
||||
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>
|
||||
{formatNumber(total)} {t("common.records")}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass overflow-auto">
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} className={`px-3 py-3 text-xs font-medium uppercase tracking-wider ${i >= 4 ? "text-right" : "text-left"}`} style={{ color: "var(--text-muted)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} className="px-3 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
|
||||
) : logs.map((log) => (
|
||||
<tr key={log.id} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
<td className="px-3 py-2.5 text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{formatDate(log.created_at)}</td>
|
||||
<td className="px-3 py-2.5" style={{ color: "var(--text-secondary)" }}>{log.display_name}</td>
|
||||
<td className="px-3 py-2.5 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-accent)", opacity: 0.7 }}>{log.real_model}</td>
|
||||
<td className="px-3 py-2.5" style={{ color: "var(--text-muted)" }}>{log.channel_name}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.prompt_tokens)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.completion_tokens)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-primary)" }}>{formatNumber(log.total_tokens)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-xs" style={{ color: "var(--text-muted)" }}>{log.use_time}ms</td>
|
||||
<td className="px-3 py-2.5 text-center">{log.is_stream ? <Zap className="inline h-3 w-3 text-yellow-500/60" /> : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
|
||||
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
|
||||
<ChevronLeft className="h-3 w-3" /> {t("common.prevPage")}
|
||||
</button>
|
||||
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
|
||||
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
|
||||
{t("common.nextPage")} <ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
app/page.tsx
Normal file
117
app/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Zap, Hash, Users, Cpu, TrendingUp, BarChart3 } from "lucide-react";
|
||||
import { StatsCard } from "@/components/StatsCard";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { TrendChart } from "@/components/charts/TrendChart";
|
||||
import { RankingBar } from "@/components/charts/RankingBar";
|
||||
import { type TimeRange, getTimeRange, buildQuery } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [granularity, setGranularity] = useState<"day" | "week" | "month">("day");
|
||||
const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens");
|
||||
const [overview, setOverview] = useState<any>(null);
|
||||
const [trends, setTrends] = useState<any[]>([]);
|
||||
const [userRank, setUserRank] = useState<any[]>([]);
|
||||
const [modelRank, setModelRank] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const tp = { start, end };
|
||||
const [ov, tr, ur, mr] = await Promise.all([
|
||||
fetch(buildQuery("/api/overview", tp)).then(r => r.json()),
|
||||
fetch(buildQuery("/api/trends", { ...tp, granularity })).then(r => r.json()),
|
||||
fetch(buildQuery("/api/rankings", { ...tp, type: "user", limit: 10 })).then(r => r.json()),
|
||||
fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()),
|
||||
]);
|
||||
setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false);
|
||||
}, [range, granularity]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const grans = [
|
||||
{ key: "day" as const, label: t("gran.day") },
|
||||
{ key: "week" as const, label: t("gran.week") },
|
||||
{ key: "month" as const, label: t("gran.month") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.h1 initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="text-2xl font-bold gradient-text">
|
||||
{t("dash.title")}
|
||||
</motion.h1>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{overview && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatsCard title={t("dash.totalCalls")} value={overview.total_calls} icon={Hash} delay={0} />
|
||||
<StatsCard title={t("dash.tokenUsage")} value={overview.total_tokens} format="tokens" icon={Zap} delay={0.05} />
|
||||
<StatsCard title={t("dash.activeUsers")} value={overview.active_users} icon={Users} delay={0.1} />
|
||||
<StatsCard title={t("dash.activeModels")} value={overview.active_models} icon={Cpu} delay={0.15} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="glass p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>{t("dash.trend")}</h2>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{grans.map(g => (
|
||||
<button key={g.key} onClick={() => setGranularity(g.key)}
|
||||
className="px-2.5 py-1 text-xs rounded transition-colors"
|
||||
style={{
|
||||
background: granularity === g.key ? "var(--btn-active-bg)" : "transparent",
|
||||
color: granularity === g.key ? "var(--text-accent)" : "var(--text-muted)",
|
||||
border: granularity === g.key ? "1px solid var(--surface-border)" : "1px solid transparent",
|
||||
}}
|
||||
>{g.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-md p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{([["total_tokens", t("metric.token")], ["calls", t("metric.calls")]] as const).map(([k, l]) => (
|
||||
<button key={k} onClick={() => setTrendMetric(k as any)}
|
||||
className="px-2.5 py-1 text-xs rounded transition-colors"
|
||||
style={{
|
||||
background: trendMetric === k ? "var(--btn-active-bg)" : "transparent",
|
||||
color: trendMetric === k ? "var(--text-accent)" : "var(--text-muted)",
|
||||
border: trendMetric === k ? "1px solid var(--surface-border)" : "1px solid transparent",
|
||||
}}
|
||||
>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-5 w-5 animate-spin rounded-full spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<TrendChart data={trends} metric={trendMetric} />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.25 }} className="glass p-5">
|
||||
<BarChart3 className="h-4 w-4 mb-1" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<RankingBar data={userRank} title={t("dash.userTop10")} />
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} className="glass p-5">
|
||||
<BarChart3 className="h-4 w-4 mb-1" style={{ color: "var(--accent-purple)", opacity: 0.6 }} />
|
||||
<RankingBar data={modelRank} title={t("dash.modelTop10")} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
app/rankings/page.tsx
Normal file
109
app/rankings/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import { Trophy, Users, Cpu, Radio } from "lucide-react";
|
||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||
import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
type Tab = "user" | "model" | "channel";
|
||||
|
||||
interface RankItem {
|
||||
rank: number; name: string; id?: number; calls: number;
|
||||
prompt_tokens: number; completion_tokens: number; total_tokens: number;
|
||||
}
|
||||
|
||||
export default function RankingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [range, setRange] = useState<TimeRange>("30d");
|
||||
const [tab, setTab] = useState<Tab>("user");
|
||||
const [data, setData] = useState<RankItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const tabConfig: Record<Tab, { label: string; icon: typeof Users }> = {
|
||||
user: { label: t("rank.user"), icon: Users },
|
||||
model: { label: t("rank.model"), icon: Cpu },
|
||||
channel: { label: t("rank.channel"), icon: Radio },
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { start, end } = getTimeRange(range);
|
||||
const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 }));
|
||||
setData(await res.json());
|
||||
setLoading(false);
|
||||
}, [range, tab]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
function detailHref(item: RankItem): string {
|
||||
if (tab === "channel") return `/detail/channel/${item.id}`;
|
||||
if (tab === "model") return `/detail/model/${encodeURIComponent(item.name)}`;
|
||||
return `/detail/user/${encodeURIComponent(item.name)}`;
|
||||
}
|
||||
|
||||
const headers = [t("th.rank"), t("th.name"), t("th.calls"), t("th.input"), t("th.output"), t("th.totalToken")];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="flex items-center gap-3">
|
||||
<Trophy className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
|
||||
<h1 className="text-2xl font-bold gradient-text">{t("rank.title")}</h1>
|
||||
</motion.div>
|
||||
<TimeRangeSelector value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 rounded-lg p-1 w-fit" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{(Object.keys(tabConfig) as Tab[]).map((key) => {
|
||||
const Icon = tabConfig[key].icon;
|
||||
return (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
className="relative flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-md transition-colors"
|
||||
style={{ color: tab === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
>
|
||||
{tab === key && (
|
||||
<motion.div layoutId="tab-bg" className="absolute inset-0 rounded-md"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }} />
|
||||
)}
|
||||
<Icon className="relative z-10 h-3.5 w-3.5" />
|
||||
<span className="relative z-10">{tabConfig[key].label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} className="glass overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||
{headers.map((h, i) => (
|
||||
<th key={h} className={`px-4 py-3 text-xs font-medium uppercase tracking-wider ${i >= 2 ? "text-right" : "text-left"}`} style={{ color: "var(--text-muted)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
|
||||
) : data.map((item, i) => (
|
||||
<motion.tr key={item.rank} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.02 }}
|
||||
className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)", borderBottomWidth: "1px", opacity: 0.01 }}>
|
||||
<td className="px-4 py-3 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{item.rank}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={detailHref(item)} className="transition-colors" style={{ color: "var(--text-accent)" }}>{item.name}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.prompt_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatTokens(item.completion_tokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
components/ClientProviders.tsx
Normal file
17
components/ClientProviders.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<Sidebar />
|
||||
<main className="ml-[220px] min-h-screen p-6 lg:p-8">{children}</main>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
114
components/Sidebar.tsx
Normal file
114
components/Sidebar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { LayoutDashboard, Trophy, ScrollText, Users, Activity, Sun, Moon, Monitor, Languages } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useTheme, type Theme } from "@/lib/theme";
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const nav = [
|
||||
{ href: "/", label: t("nav.overview"), icon: LayoutDashboard },
|
||||
{ href: "/rankings", label: t("nav.rankings"), icon: Trophy },
|
||||
{ href: "/aggregation", label: t("nav.aggregation"), icon: Users },
|
||||
{ href: "/logs", label: t("nav.logs"), icon: ScrollText },
|
||||
];
|
||||
|
||||
const themes: { value: Theme; icon: typeof Sun; label: string }[] = [
|
||||
{ value: "light", icon: Sun, label: t("theme.light") },
|
||||
{ value: "dark", icon: Moon, label: t("theme.dark") },
|
||||
{ value: "system", icon: Monitor, label: t("theme.system") },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-[220px] flex-col glass !rounded-none !border-l-0 !border-t-0 !border-b-0">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-t px-5" style={{ borderColor: "var(--surface-border)" }}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg border" style={{ borderColor: "var(--surface-border)", background: "var(--btn-active-bg)" }}>
|
||||
<Activity className="h-4 w-4 text-t-accent" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold tracking-wide text-t-primary" style={{ color: "var(--text-primary)" }}>Neural</span>
|
||||
<span className="text-sm font-light tracking-wide" style={{ color: "var(--accent)" }}>Pulse</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3 pt-4">
|
||||
{nav.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<motion.div
|
||||
className="relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors"
|
||||
style={{ color: active ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
whileHover={{ x: 2 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative z-10 h-4 w-4" />
|
||||
<span className="relative z-10 font-medium">{item.label}</span>
|
||||
{active && (
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 h-5 w-[2px] -translate-y-1/2 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
layoutId="sidebar-indicator"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="space-y-3 p-4 border-t" style={{ borderColor: "var(--surface-border)" }}>
|
||||
{/* Theme switcher */}
|
||||
<div className="flex gap-1 rounded-lg p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{themes.map(({ value, icon: Icon }) => (
|
||||
<button key={value} onClick={() => setTheme(value)}
|
||||
className="flex-1 flex items-center justify-center rounded-md py-1.5 transition-colors"
|
||||
style={{
|
||||
background: theme === value ? "var(--btn-active-bg)" : "transparent",
|
||||
color: theme === value ? "var(--text-accent)" : "var(--text-muted)",
|
||||
border: theme === value ? "1px solid var(--surface-border)" : "1px solid transparent",
|
||||
}}
|
||||
title={themes.find(t => t.value === value)?.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Language switcher */}
|
||||
<button
|
||||
onClick={() => setLocale(locale === "zh" ? "en" : "zh")}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-xs transition-colors"
|
||||
style={{ color: "var(--text-muted)", background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}
|
||||
>
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{locale === "zh" ? "English" : "中文"}</span>
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.5)]" />
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{t("common.systemOnline")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
39
components/StatsCard.tsx
Normal file
39
components/StatsCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
format?: "number" | "tokens";
|
||||
icon: LucideIcon;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function StatsCard({ title, value, format = "number", icon: Icon, delay = 0 }: StatsCardProps) {
|
||||
const display = format === "tokens" ? formatTokens(value) : formatNumber(value);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="glass group p-5 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{title}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-tight font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>
|
||||
{display}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}>
|
||||
<Icon className="h-4 w-4" style={{ color: "var(--accent)", opacity: 0.7 }} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
41
components/TimeRangeSelector.tsx
Normal file
41
components/TimeRangeSelector.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type TimeRange } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function TimeRangeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TimeRange;
|
||||
onChange: (v: TimeRange) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ranges: { label: string; value: TimeRange }[] = [
|
||||
{ label: t("time.today"), value: "today" },
|
||||
{ label: t("time.7d"), value: "7d" },
|
||||
{ label: t("time.30d"), value: "30d" },
|
||||
{ label: t("time.all"), value: "all" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{ranges.map((r) => (
|
||||
<button key={r.value} onClick={() => onChange(r.value)}
|
||||
className="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
style={{ color: value === r.value ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
>
|
||||
{value === r.value && (
|
||||
<motion.div layoutId="time-range-bg"
|
||||
className="absolute inset-0 rounded-md"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{r.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/charts/RankingBar.tsx
Normal file
68
components/charts/RankingBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { formatTokens } from "@/lib/utils";
|
||||
|
||||
interface RankItem {
|
||||
name: string;
|
||||
total_tokens: number;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
const BAR_COLORS = [
|
||||
"#00e5ff", "#00bcd4", "#0097a7", "#7c4dff",
|
||||
"#651fff", "#536dfe", "#448aff", "#40c4ff",
|
||||
"#18ffff", "#84ffff",
|
||||
];
|
||||
|
||||
export function RankingBar({
|
||||
data,
|
||||
title,
|
||||
}: {
|
||||
data: RankItem[];
|
||||
title: string;
|
||||
}) {
|
||||
if (!data.length) return null;
|
||||
const sliced = data.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={sliced}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,229,255,0.06)" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 10, fill: "rgba(200,214,229,0.4)" }} stroke="rgba(0,229,255,0.1)" />
|
||||
<YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11, fill: "rgba(200,214,229,0.6)" }} stroke="transparent" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(6,8,13,0.95)",
|
||||
border: "1px solid rgba(0,229,255,0.2)",
|
||||
borderRadius: "8px",
|
||||
color: "#c8d6e5",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
formatter={(v) => [formatTokens(Number(v)), "Total Tokens"]}
|
||||
/>
|
||||
<Bar dataKey="total_tokens" radius={[0, 4, 4, 0]}>
|
||||
{sliced.map((_, i) => (
|
||||
<Cell key={i} fill={BAR_COLORS[i % BAR_COLORS.length]} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
components/charts/TrendChart.tsx
Normal file
113
components/charts/TrendChart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface TrendPoint { date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; }
|
||||
|
||||
export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint[]; metric?: "total_tokens" | "calls" }) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
if (!data.length)
|
||||
return <div className="flex h-64 items-center justify-center" style={{ color: "var(--text-muted)" }}>{t("common.noData")}</div>;
|
||||
|
||||
// 本地化日期格式
|
||||
const formatDateLabel = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
if (locale === "zh") {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const tooltipStyle = {
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--surface-border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "12px",
|
||||
backdropFilter: "blur(16px)",
|
||||
};
|
||||
|
||||
// Token 模式:输入和输出数量级差距大,用双 Y 轴
|
||||
if (metric === "total_tokens") {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 60, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradCyan" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--accent-purple)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradPurple" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--accent-purple)" />
|
||||
<stop offset="100%" stopColor="var(--accent-pink)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
{/* 左 Y 轴:输入 */}
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(v) => formatTokens(v)}
|
||||
tick={{ fontSize: 10 }}
|
||||
stroke="var(--chart-grid)"
|
||||
label={{ value: t("th.input"), angle: -90, position: "insideLeft", style: { fontSize: 10, fill: "var(--text-muted)" } }}
|
||||
/>
|
||||
{/* 右 Y 轴:输出 */}
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(v) => formatTokens(v)}
|
||||
tick={{ fontSize: 10 }}
|
||||
stroke="var(--chart-grid)"
|
||||
label={{ value: t("th.output"), angle: 90, position: "insideRight", style: { fontSize: 10, fill: "var(--text-muted)" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(label) => {
|
||||
const d = new Date(String(label));
|
||||
return locale === "zh"
|
||||
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
formatTokens(Number(value)),
|
||||
name === t("th.input") ? t("th.input") : t("th.output"),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="prompt_tokens" name={t("th.input")} stroke="url(#gradCyan)" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="completion_tokens" name={t("th.output")} stroke="url(#gradPurple)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 调用量模式:单 Y 轴
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
<YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(label) => {
|
||||
const d = new Date(String(label));
|
||||
return locale === "zh"
|
||||
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
formatTokens(Number(value)),
|
||||
name === t("th.calls") ? t("th.calls") : String(name),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="calls" name={t("th.calls")} stroke="var(--accent)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
analytics:
|
||||
build: .
|
||||
container_name: new-api-analytics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8019:8019"
|
||||
env_file:
|
||||
- .env.production
|
||||
networks:
|
||||
- sinobridge
|
||||
|
||||
networks:
|
||||
sinobridge:
|
||||
external: true
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
18
lib/db.ts
Normal file
18
lib/db.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.PG_CONNECTION_STRING,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
export async function query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
params?: (string | number | boolean | null)[]
|
||||
): Promise<T[]> {
|
||||
const { rows } = await pool.query<T>(text, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default pool;
|
||||
190
lib/i18n.tsx
Normal file
190
lib/i18n.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const translations = {
|
||||
zh: {
|
||||
// nav
|
||||
"nav.overview": "总览",
|
||||
"nav.rankings": "排名",
|
||||
"nav.aggregation": "聚合",
|
||||
"nav.logs": "日志",
|
||||
// common
|
||||
"common.loading": "加载中...",
|
||||
"common.noData": "暂无数据",
|
||||
"common.records": "条记录",
|
||||
"common.systemOnline": "系统在线",
|
||||
"common.back": "返回",
|
||||
"common.backToRankings": "返回排名",
|
||||
"common.prevPage": "上一页",
|
||||
"common.nextPage": "下一页",
|
||||
"common.share": "占比",
|
||||
// time range
|
||||
"time.today": "今日",
|
||||
"time.7d": "7 天",
|
||||
"time.30d": "30 天",
|
||||
"time.all": "全部",
|
||||
// granularity
|
||||
"gran.day": "日",
|
||||
"gran.week": "周",
|
||||
"gran.month": "月",
|
||||
// metrics
|
||||
"metric.token": "Token",
|
||||
"metric.calls": "调用量",
|
||||
// dashboard
|
||||
"dash.title": "仪表盘",
|
||||
"dash.totalCalls": "调用次数",
|
||||
"dash.tokenUsage": "Token 消耗",
|
||||
"dash.activeUsers": "活跃用户",
|
||||
"dash.activeModels": "活跃模型",
|
||||
"dash.trend": "使用趋势",
|
||||
"dash.userTop10": "用户 Top 10 — Token 消耗",
|
||||
"dash.modelTop10": "模型 Top 10 — Token 消耗",
|
||||
// table headers
|
||||
"th.rank": "#",
|
||||
"th.name": "名称",
|
||||
"th.user": "用户",
|
||||
"th.calls": "调用次数",
|
||||
"th.input": "输入",
|
||||
"th.output": "输出",
|
||||
"th.totalToken": "总 Token",
|
||||
"th.time": "时间",
|
||||
"th.realModel": "真实模型",
|
||||
"th.channel": "渠道",
|
||||
"th.latency": "耗时",
|
||||
// rankings
|
||||
"rank.title": "排名",
|
||||
"rank.user": "用户",
|
||||
"rank.model": "模型",
|
||||
"rank.channel": "渠道",
|
||||
// aggregation
|
||||
"agg.title": "用户聚合",
|
||||
"agg.userCount": "用户数",
|
||||
"agg.totalCalls": "总调用",
|
||||
"agg.totalToken": "总 Token",
|
||||
// logs
|
||||
"logs.title": "日志明细",
|
||||
"logs.filterUser": "用户名",
|
||||
"logs.filterModel": "模型",
|
||||
"logs.filterToken": "Token 名称",
|
||||
// detail
|
||||
"detail.user": "用户",
|
||||
"detail.model": "模型",
|
||||
"detail.channel": "渠道",
|
||||
"detail.trend": "使用趋势",
|
||||
"detail.modelDist": "模型分布",
|
||||
"detail.userDist": "用户分布",
|
||||
// theme
|
||||
"theme.light": "浅色",
|
||||
"theme.dark": "深色",
|
||||
"theme.system": "系统",
|
||||
},
|
||||
en: {
|
||||
"nav.overview": "Overview",
|
||||
"nav.rankings": "Rankings",
|
||||
"nav.aggregation": "Aggregation",
|
||||
"nav.logs": "Logs",
|
||||
"common.loading": "Loading...",
|
||||
"common.noData": "No data",
|
||||
"common.records": "records",
|
||||
"common.systemOnline": "System Online",
|
||||
"common.back": "Back",
|
||||
"common.backToRankings": "Back to Rankings",
|
||||
"common.prevPage": "Previous",
|
||||
"common.nextPage": "Next",
|
||||
"common.share": "Share",
|
||||
"time.today": "Today",
|
||||
"time.7d": "7 Days",
|
||||
"time.30d": "30 Days",
|
||||
"time.all": "All",
|
||||
"gran.day": "Day",
|
||||
"gran.week": "Week",
|
||||
"gran.month": "Month",
|
||||
"metric.token": "Token",
|
||||
"metric.calls": "Calls",
|
||||
"dash.title": "Dashboard",
|
||||
"dash.totalCalls": "Total Calls",
|
||||
"dash.tokenUsage": "Token Usage",
|
||||
"dash.activeUsers": "Active Users",
|
||||
"dash.activeModels": "Active Models",
|
||||
"dash.trend": "Usage Trend",
|
||||
"dash.userTop10": "User Top 10 — Token Usage",
|
||||
"dash.modelTop10": "Model Top 10 — Token Usage",
|
||||
"th.rank": "#",
|
||||
"th.name": "Name",
|
||||
"th.user": "User",
|
||||
"th.calls": "Calls",
|
||||
"th.input": "Input",
|
||||
"th.output": "Output",
|
||||
"th.totalToken": "Total Token",
|
||||
"th.time": "Time",
|
||||
"th.realModel": "Real Model",
|
||||
"th.channel": "Channel",
|
||||
"th.latency": "Latency",
|
||||
"rank.title": "Rankings",
|
||||
"rank.user": "User",
|
||||
"rank.model": "Model",
|
||||
"rank.channel": "Channel",
|
||||
"agg.title": "User Aggregation",
|
||||
"agg.userCount": "Users",
|
||||
"agg.totalCalls": "Total Calls",
|
||||
"agg.totalToken": "Total Token",
|
||||
"logs.title": "Log Details",
|
||||
"logs.filterUser": "Username",
|
||||
"logs.filterModel": "Model",
|
||||
"logs.filterToken": "Token Name",
|
||||
"detail.user": "User",
|
||||
"detail.model": "Model",
|
||||
"detail.channel": "Channel",
|
||||
"detail.trend": "Usage Trend",
|
||||
"detail.modelDist": "Model Distribution",
|
||||
"detail.userDist": "User Distribution",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.system": "System",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TranslationKey = keyof typeof translations.zh;
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
t: (key: TranslationKey) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType>({
|
||||
locale: "zh",
|
||||
setLocale: () => {},
|
||||
t: (key) => key,
|
||||
});
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>("zh");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("locale") as Locale | null;
|
||||
if (saved && (saved === "zh" || saved === "en")) setLocale(saved);
|
||||
}, []);
|
||||
|
||||
const handleSetLocale = (l: Locale) => {
|
||||
setLocale(l);
|
||||
localStorage.setItem("locale", l);
|
||||
};
|
||||
|
||||
const t = (key: TranslationKey): string => {
|
||||
return translations[locale][key] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
524
lib/queries.ts
Normal file
524
lib/queries.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { query } from "./db";
|
||||
|
||||
// 真实模型名表达式:优先取 other 里的 upstream_model_name
|
||||
const REAL_MODEL = `COALESCE(
|
||||
CASE WHEN other IS NOT NULL AND other != '' AND other::jsonb ? 'upstream_model_name'
|
||||
THEN other::jsonb->>'upstream_model_name' END,
|
||||
model_name)`;
|
||||
|
||||
// 时间条件构建
|
||||
function timeWhere(
|
||||
params: (string | number | boolean | null)[],
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
): string {
|
||||
let where = "type = 2";
|
||||
if (startTs) {
|
||||
params.push(startTs);
|
||||
where += ` AND created_at >= $${params.length}`;
|
||||
}
|
||||
if (endTs) {
|
||||
params.push(endTs);
|
||||
where += ` AND created_at < $${params.length}`;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
// ── 总览 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface OverviewData {
|
||||
total_calls: number;
|
||||
total_tokens: number;
|
||||
total_prompt: number;
|
||||
total_completion: number;
|
||||
total_quota: number;
|
||||
active_users: number;
|
||||
active_models: number;
|
||||
active_channels: number;
|
||||
}
|
||||
|
||||
export async function getOverview(
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
): Promise<OverviewData> {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
|
||||
const rows = await query(
|
||||
`SELECT
|
||||
COUNT(*)::int as total_calls,
|
||||
COALESCE(SUM(prompt_tokens + completion_tokens), 0)::bigint as total_tokens,
|
||||
COALESCE(SUM(prompt_tokens), 0)::bigint as total_prompt,
|
||||
COALESCE(SUM(completion_tokens), 0)::bigint as total_completion,
|
||||
COALESCE(SUM(quota), 0)::bigint as total_quota,
|
||||
COUNT(DISTINCT user_id)::int as active_users,
|
||||
COUNT(DISTINCT ${REAL_MODEL})::int as active_models,
|
||||
COUNT(DISTINCT channel_id)::int as active_channels
|
||||
FROM logs WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const r = rows[0];
|
||||
return {
|
||||
total_calls: Number(r.total_calls),
|
||||
total_tokens: Number(r.total_tokens),
|
||||
total_prompt: Number(r.total_prompt),
|
||||
total_completion: Number(r.total_completion),
|
||||
total_quota: Number(r.total_quota),
|
||||
active_users: Number(r.active_users),
|
||||
active_models: Number(r.active_models),
|
||||
active_channels: Number(r.active_channels),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 趋势 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
calls: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
quota: number;
|
||||
}
|
||||
|
||||
export async function getTrends(
|
||||
granularity: "day" | "week" | "month" = "day",
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
): Promise<TrendPoint[]> {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
|
||||
const truncExpr =
|
||||
granularity === "day"
|
||||
? `(to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date`
|
||||
: `date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date`;
|
||||
|
||||
const rows = await query(
|
||||
`SELECT
|
||||
${truncExpr} as date,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens), 0)::bigint as prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0)::bigint as completion_tokens,
|
||||
COALESCE(SUM(quota), 0)::bigint as quota
|
||||
FROM logs WHERE ${where}
|
||||
GROUP BY date ORDER BY date`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
date: String(r.date).slice(0, 10),
|
||||
calls: Number(r.calls),
|
||||
prompt_tokens: Number(r.prompt_tokens),
|
||||
completion_tokens: Number(r.completion_tokens),
|
||||
total_tokens: Number(r.prompt_tokens) + Number(r.completion_tokens),
|
||||
quota: Number(r.quota),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 排名 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface RankingItem {
|
||||
rank: number;
|
||||
name: string;
|
||||
id?: number;
|
||||
calls: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
quota: number;
|
||||
quota_usd: number;
|
||||
}
|
||||
|
||||
// 用户显示名称映射
|
||||
async function getDisplayNames(): Promise<Record<number, string>> {
|
||||
const rows = await query(
|
||||
"SELECT id, display_name FROM users WHERE display_name IS NOT NULL AND display_name != ''"
|
||||
);
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.display_name]));
|
||||
}
|
||||
|
||||
// 渠道名称映射
|
||||
async function getChannelNames(): Promise<Record<number, string>> {
|
||||
const rows = await query("SELECT id, name FROM channels");
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.name]));
|
||||
}
|
||||
|
||||
export async function getUserRanking(
|
||||
startTs?: number,
|
||||
endTs?: number,
|
||||
limit = 50
|
||||
): Promise<RankingItem[]> {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(limit);
|
||||
|
||||
const displayNames = await getDisplayNames();
|
||||
|
||||
const rows = await query(
|
||||
`SELECT user_id, username,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens), 0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens), 0)::bigint as completion,
|
||||
COALESCE(SUM(quota), 0)::bigint as quota
|
||||
FROM logs WHERE ${where}
|
||||
GROUP BY user_id, username
|
||||
ORDER BY COALESCE(SUM(prompt_tokens),0) + COALESCE(SUM(completion_tokens),0) DESC
|
||||
LIMIT $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
name: displayNames[r.user_id] || r.username,
|
||||
id: Number(r.user_id),
|
||||
calls: Number(r.calls),
|
||||
prompt_tokens: Number(r.prompt),
|
||||
completion_tokens: Number(r.completion),
|
||||
total_tokens: Number(r.prompt) + Number(r.completion),
|
||||
quota: Number(r.quota),
|
||||
quota_usd: Number(r.quota) / 500000,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getModelRanking(
|
||||
startTs?: number,
|
||||
endTs?: number,
|
||||
limit = 50
|
||||
): Promise<RankingItem[]> {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(limit);
|
||||
|
||||
const rows = await query(
|
||||
`SELECT ${REAL_MODEL} as model,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens), 0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens), 0)::bigint as completion,
|
||||
COALESCE(SUM(quota), 0)::bigint as quota
|
||||
FROM logs WHERE ${where}
|
||||
GROUP BY model
|
||||
ORDER BY COALESCE(SUM(prompt_tokens),0) + COALESCE(SUM(completion_tokens),0) DESC
|
||||
LIMIT $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
name: r.model || "(unknown)",
|
||||
calls: Number(r.calls),
|
||||
prompt_tokens: Number(r.prompt),
|
||||
completion_tokens: Number(r.completion),
|
||||
total_tokens: Number(r.prompt) + Number(r.completion),
|
||||
quota: Number(r.quota),
|
||||
quota_usd: Number(r.quota) / 500000,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getChannelRanking(
|
||||
startTs?: number,
|
||||
endTs?: number,
|
||||
limit = 50
|
||||
): Promise<RankingItem[]> {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(limit);
|
||||
|
||||
const channelNames = await getChannelNames();
|
||||
|
||||
const rows = await query(
|
||||
`SELECT channel_id,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens), 0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens), 0)::bigint as completion,
|
||||
COALESCE(SUM(quota), 0)::bigint as quota
|
||||
FROM logs WHERE ${where}
|
||||
GROUP BY channel_id
|
||||
ORDER BY COALESCE(SUM(prompt_tokens),0) + COALESCE(SUM(completion_tokens),0) DESC
|
||||
LIMIT $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
name: channelNames[r.channel_id] || `已删除(${r.channel_id})`,
|
||||
id: Number(r.channel_id),
|
||||
calls: Number(r.calls),
|
||||
prompt_tokens: Number(r.prompt),
|
||||
completion_tokens: Number(r.completion),
|
||||
total_tokens: Number(r.prompt) + Number(r.completion),
|
||||
quota: Number(r.quota),
|
||||
quota_usd: Number(r.quota) / 500000,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 下钻详情 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DetailBreakdown {
|
||||
name: string;
|
||||
calls: number;
|
||||
total_tokens: number;
|
||||
quota: number;
|
||||
}
|
||||
|
||||
export async function getUserDetail(
|
||||
username: string,
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
) {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(username);
|
||||
|
||||
// 用户总览
|
||||
const overview = await query(
|
||||
`SELECT COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens),0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens),0)::bigint as completion,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where} AND username = $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
// 用户的模型分布
|
||||
const params2: (string | number | boolean | null)[] = [];
|
||||
const where2 = timeWhere(params2, startTs, endTs);
|
||||
params2.push(username);
|
||||
|
||||
const models = await query(
|
||||
`SELECT ${REAL_MODEL} as model,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens + completion_tokens),0)::bigint as tokens,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where2} AND username = $${params2.length}
|
||||
GROUP BY model
|
||||
ORDER BY tokens DESC LIMIT 20`,
|
||||
params2
|
||||
);
|
||||
|
||||
const o = overview[0];
|
||||
return {
|
||||
calls: Number(o.calls),
|
||||
prompt_tokens: Number(o.prompt),
|
||||
completion_tokens: Number(o.completion),
|
||||
total_tokens: Number(o.prompt) + Number(o.completion),
|
||||
quota: Number(o.quota),
|
||||
models: models.map((m) => ({
|
||||
name: m.model,
|
||||
calls: Number(m.calls),
|
||||
total_tokens: Number(m.tokens),
|
||||
quota: Number(m.quota),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModelDetail(
|
||||
model: string,
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
) {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(model);
|
||||
|
||||
const overview = await query(
|
||||
`SELECT COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens),0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens),0)::bigint as completion,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where} AND ${REAL_MODEL} = $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
const params2: (string | number | boolean | null)[] = [];
|
||||
const where2 = timeWhere(params2, startTs, endTs);
|
||||
params2.push(model);
|
||||
|
||||
const displayNames = await getDisplayNames();
|
||||
const users = await query(
|
||||
`SELECT user_id, username,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens + completion_tokens),0)::bigint as tokens,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where2} AND ${REAL_MODEL} = $${params2.length}
|
||||
GROUP BY user_id, username
|
||||
ORDER BY tokens DESC LIMIT 20`,
|
||||
params2
|
||||
);
|
||||
|
||||
const o = overview[0];
|
||||
return {
|
||||
calls: Number(o.calls),
|
||||
prompt_tokens: Number(o.prompt),
|
||||
completion_tokens: Number(o.completion),
|
||||
total_tokens: Number(o.prompt) + Number(o.completion),
|
||||
quota: Number(o.quota),
|
||||
users: users.map((u) => ({
|
||||
name: displayNames[u.user_id] || u.username,
|
||||
calls: Number(u.calls),
|
||||
total_tokens: Number(u.tokens),
|
||||
quota: Number(u.quota),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChannelDetail(
|
||||
channelId: number,
|
||||
startTs?: number,
|
||||
endTs?: number
|
||||
) {
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
const where = timeWhere(params, startTs, endTs);
|
||||
params.push(channelId);
|
||||
|
||||
const channelNames = await getChannelNames();
|
||||
|
||||
const overview = await query(
|
||||
`SELECT COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens),0)::bigint as prompt,
|
||||
COALESCE(SUM(completion_tokens),0)::bigint as completion,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where} AND channel_id = $${params.length}`,
|
||||
params
|
||||
);
|
||||
|
||||
const params2: (string | number | boolean | null)[] = [];
|
||||
const where2 = timeWhere(params2, startTs, endTs);
|
||||
params2.push(channelId);
|
||||
|
||||
const models = await query(
|
||||
`SELECT ${REAL_MODEL} as model,
|
||||
COUNT(*)::int as calls,
|
||||
COALESCE(SUM(prompt_tokens + completion_tokens),0)::bigint as tokens,
|
||||
COALESCE(SUM(quota),0)::bigint as quota
|
||||
FROM logs WHERE ${where2} AND channel_id = $${params2.length}
|
||||
GROUP BY model
|
||||
ORDER BY tokens DESC LIMIT 20`,
|
||||
params2
|
||||
);
|
||||
|
||||
const o = overview[0];
|
||||
return {
|
||||
channel_name: channelNames[channelId] || `已删除(${channelId})`,
|
||||
calls: Number(o.calls),
|
||||
prompt_tokens: Number(o.prompt),
|
||||
completion_tokens: Number(o.completion),
|
||||
total_tokens: Number(o.prompt) + Number(o.completion),
|
||||
quota: Number(o.quota),
|
||||
models: models.map((m) => ({
|
||||
name: m.model,
|
||||
calls: Number(m.calls),
|
||||
total_tokens: Number(m.tokens),
|
||||
quota: Number(m.quota),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 明细日志 ──────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
real_model: string;
|
||||
request_model: string;
|
||||
channel_name: string;
|
||||
channel_id: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
quota: number;
|
||||
quota_usd: number;
|
||||
use_time: number;
|
||||
is_stream: boolean;
|
||||
token_name: string;
|
||||
}
|
||||
|
||||
export interface LogsResult {
|
||||
logs: LogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export async function getLogs(options: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startTs?: number;
|
||||
endTs?: number;
|
||||
username?: string;
|
||||
model?: string;
|
||||
channelId?: number;
|
||||
tokenName?: string;
|
||||
}): Promise<LogsResult> {
|
||||
const { page = 1, pageSize = 100 } = options;
|
||||
const params: (string | number | boolean | null)[] = [];
|
||||
let where = timeWhere(params, options.startTs, options.endTs);
|
||||
|
||||
if (options.username) {
|
||||
params.push(options.username);
|
||||
where += ` AND username = $${params.length}`;
|
||||
}
|
||||
if (options.model) {
|
||||
params.push(options.model);
|
||||
where += ` AND ${REAL_MODEL} = $${params.length}`;
|
||||
}
|
||||
if (options.channelId) {
|
||||
params.push(options.channelId);
|
||||
where += ` AND channel_id = $${params.length}`;
|
||||
}
|
||||
if (options.tokenName) {
|
||||
params.push(`%${options.tokenName}%`);
|
||||
where += ` AND token_name ILIKE $${params.length}`;
|
||||
}
|
||||
|
||||
// Count
|
||||
const countRows = await query(
|
||||
`SELECT COUNT(*)::int as total FROM logs WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = Number(countRows[0].total);
|
||||
|
||||
// Data
|
||||
const offset = (page - 1) * pageSize;
|
||||
const dataParams = [...params, pageSize, offset];
|
||||
|
||||
const displayNames = await getDisplayNames();
|
||||
const channelNames = await getChannelNames();
|
||||
|
||||
const rows = await query(
|
||||
`SELECT id, created_at, user_id, username, model_name,
|
||||
${REAL_MODEL} as real_model,
|
||||
channel_id, prompt_tokens, completion_tokens, quota,
|
||||
use_time, is_stream, token_name
|
||||
FROM logs WHERE ${where}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
dataParams
|
||||
);
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
logs: rows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
created_at: new Date(Number(r.created_at) * 1000).toISOString(),
|
||||
username: r.username,
|
||||
display_name: displayNames[r.user_id] || r.username,
|
||||
real_model: r.real_model || r.model_name,
|
||||
request_model: r.model_name,
|
||||
channel_name: channelNames[r.channel_id] || `已删除(${r.channel_id})`,
|
||||
channel_id: Number(r.channel_id),
|
||||
prompt_tokens: Number(r.prompt_tokens),
|
||||
completion_tokens: Number(r.completion_tokens),
|
||||
total_tokens: Number(r.prompt_tokens) + Number(r.completion_tokens),
|
||||
quota: Number(r.quota),
|
||||
quota_usd: Number(r.quota) / 500000,
|
||||
use_time: Number(r.use_time),
|
||||
is_stream: Boolean(r.is_stream),
|
||||
token_name: r.token_name || "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
74
lib/theme.tsx
Normal file
74
lib/theme.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
resolved: "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: "system",
|
||||
setTheme: () => {},
|
||||
resolved: "dark",
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>("system");
|
||||
const [resolved, setResolved] = useState<"light" | "dark">("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("theme") as Theme | null;
|
||||
if (saved && ["light", "dark", "system"].includes(saved)) {
|
||||
setThemeState(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const resolve = () => {
|
||||
if (theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
const r = resolve();
|
||||
setResolved(r);
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(r);
|
||||
root.setAttribute("data-theme", r);
|
||||
|
||||
if (theme === "system") {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
const nr = mq.matches ? "dark" : "light";
|
||||
setResolved(nr);
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(nr);
|
||||
root.setAttribute("data-theme", nr);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
setThemeState(t);
|
||||
localStorage.setItem("theme", t);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolved }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
54
lib/utils.ts
Normal file
54
lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function formatUSD(n: number): string {
|
||||
if (n >= 1000) return `$${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
||||
if (n >= 1) return `$${n.toFixed(2)}`;
|
||||
if (n >= 0.01) return `$${n.toFixed(3)}`;
|
||||
return `$${n.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return dayjs(iso).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
// 预设时间范围
|
||||
export type TimeRange = "today" | "7d" | "30d" | "all" | "custom";
|
||||
|
||||
export function getTimeRange(range: TimeRange): { start?: number; end?: number } {
|
||||
const now = dayjs();
|
||||
switch (range) {
|
||||
case "today":
|
||||
return { start: now.startOf("day").unix(), end: now.unix() };
|
||||
case "7d":
|
||||
return { start: now.subtract(7, "day").startOf("day").unix(), end: now.unix() };
|
||||
case "30d":
|
||||
return { start: now.subtract(30, "day").startOf("day").unix(), end: now.unix() };
|
||||
case "all":
|
||||
return {};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildQuery(
|
||||
base: string,
|
||||
params: Record<string, string | number | undefined>
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") sp.set(k, String(v));
|
||||
}
|
||||
const qs = sp.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "new-api-analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.2",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"ignoreScripts": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
],
|
||||
"trustedDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user