feat: add user token distribution tab
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, startTransition } from "react";
|
import { Fragment, useEffect, useState, useCallback, startTransition } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp, ChevronRight, KeyRound } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { StatsCard } from "@/components/StatsCard";
|
import { StatsCard } from "@/components/StatsCard";
|
||||||
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
|
||||||
import { TrendChart } from "@/components/charts/TrendChart";
|
import { TrendChart } from "@/components/charts/TrendChart";
|
||||||
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
|
import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils";
|
||||||
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort";
|
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort";
|
||||||
|
import { getPrimaryModelNames, getSharePercent, getTokenDisplayName, getTokenRowKey, shouldShowTokenTab, sortTokenBreakdown, type TokenBreakdownItem } from "@/lib/token-breakdown";
|
||||||
import { getDetailStats, type DetailStatKey } from "@/lib/detail-stats";
|
import { getDetailStats, type DetailStatKey } from "@/lib/detail-stats";
|
||||||
import { useTimeRange } from "@/lib/time-range-context";
|
import { useTimeRange } from "@/lib/time-range-context";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
@@ -20,9 +21,12 @@ interface DetailData {
|
|||||||
total_tokens: number; quota: number; display_name?: string;
|
total_tokens: number; quota: number; display_name?: string;
|
||||||
models?: DetailBreakdownItem[];
|
models?: DetailBreakdownItem[];
|
||||||
users?: DetailBreakdownItem[];
|
users?: DetailBreakdownItem[];
|
||||||
|
tokens?: TokenBreakdownItem[];
|
||||||
channel_name?: string;
|
channel_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BreakdownTab = "models" | "tokens";
|
||||||
|
|
||||||
export default function DetailPage() {
|
export default function DetailPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -37,6 +41,8 @@ export default function DetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [breakdownSortKey, setBreakdownSortKey] = useState<DetailBreakdownSortKey>("total_tokens");
|
const [breakdownSortKey, setBreakdownSortKey] = useState<DetailBreakdownSortKey>("total_tokens");
|
||||||
const [breakdownSortAsc, setBreakdownSortAsc] = useState(false);
|
const [breakdownSortAsc, setBreakdownSortAsc] = useState(false);
|
||||||
|
const [breakdownTab, setBreakdownTab] = useState<BreakdownTab>("models");
|
||||||
|
const [expandedTokens, setExpandedTokens] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
startTransition(() => setLoading(true));
|
startTransition(() => setLoading(true));
|
||||||
@@ -57,8 +63,12 @@ export default function DetailPage() {
|
|||||||
|
|
||||||
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
|
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
|
||||||
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
|
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
|
||||||
const breakdownItems = data?.models || data?.users || [];
|
const modelBreakdownItems = data?.models || data?.users || [];
|
||||||
const sortedBreakdownItems = sortDetailBreakdown(breakdownItems, breakdownSortKey, breakdownSortAsc);
|
const tokenBreakdownItems = data?.tokens || [];
|
||||||
|
const showTokenTab = shouldShowTokenTab(type);
|
||||||
|
const activeBreakdownTab = showTokenTab ? breakdownTab : "models";
|
||||||
|
const sortedBreakdownItems = sortDetailBreakdown(modelBreakdownItems, breakdownSortKey, breakdownSortAsc);
|
||||||
|
const sortedTokenItems = sortTokenBreakdown(tokenBreakdownItems, breakdownSortKey, breakdownSortAsc);
|
||||||
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
|
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
|
||||||
|
|
||||||
function handleBreakdownSort(key: DetailBreakdownSortKey) {
|
function handleBreakdownSort(key: DetailBreakdownSortKey) {
|
||||||
@@ -66,6 +76,15 @@ export default function DetailPage() {
|
|||||||
else { setBreakdownSortKey(key); setBreakdownSortAsc(false); }
|
else { setBreakdownSortKey(key); setBreakdownSortAsc(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleToken(tokenName: string) {
|
||||||
|
setExpandedTokens((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(tokenName)) next.delete(tokenName);
|
||||||
|
else next.add(tokenName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => {
|
const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => {
|
||||||
if (breakdownSortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
|
if (breakdownSortKey !== col) return <ArrowUpDown className="h-3 w-3" style={{ color: "var(--text-muted)", opacity: 0.4 }} />;
|
||||||
return breakdownSortAsc
|
return breakdownSortAsc
|
||||||
@@ -90,6 +109,112 @@ export default function DetailPage() {
|
|||||||
cache_read_tokens: BookOpen,
|
cache_read_tokens: BookOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderEmptyBreakdown() {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-10 text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{t("common.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTokenDistribution() {
|
||||||
|
if (sortedTokenItems.length === 0) return renderEmptyBreakdown();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{breakdownColumns.slice(1).map((col) => {
|
||||||
|
const key = col.key;
|
||||||
|
return (
|
||||||
|
<th key={col.label}
|
||||||
|
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
|
||||||
|
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||||
|
onClick={key ? () => handleBreakdownSort(key) : undefined}
|
||||||
|
>
|
||||||
|
{key ? (
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{col.label} {renderBreakdownSortIcon(key)}
|
||||||
|
</span>
|
||||||
|
) : col.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.primaryModels")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedTokenItems.map((item) => {
|
||||||
|
const isExpanded = expandedTokens.has(item.name);
|
||||||
|
const tokenLabel = getTokenDisplayName(item.name, t("detail.unnamedToken"));
|
||||||
|
const userShare = getSharePercent(item.total_tokens, data?.total_tokens ?? 0);
|
||||||
|
return (
|
||||||
|
<Fragment key={getTokenRowKey(item.name)}>
|
||||||
|
<tr className="row-glow cursor-pointer transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }} onClick={() => toggleToken(item.name)}>
|
||||||
|
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.85 }}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isExpanded ? t("detail.collapseToken") : t("detail.expandToken")}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md transition-colors"
|
||||||
|
style={{ color: "var(--text-muted)", background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||||
|
onClick={(event) => { event.stopPropagation(); toggleToken(item.name); }}
|
||||||
|
>
|
||||||
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<KeyRound className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.55 }} />
|
||||||
|
<span>{tokenLabel}</span>
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{userShare.toFixed(1)}%</td>
|
||||||
|
<td className="px-5 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{getPrimaryModelNames(item.models) || t("common.noData")}</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
|
<td colSpan={6} className="px-5 pb-4 pt-0">
|
||||||
|
<div className="ml-8 overflow-hidden rounded-lg" style={{ border: "1px solid var(--surface-border)", background: "var(--row-hover)" }}>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
|
<th className="px-4 py-2 text-left font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.model")}</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.cost")}</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{item.models.map((model) => {
|
||||||
|
const modelShare = getSharePercent(model.total_tokens, item.total_tokens);
|
||||||
|
return (
|
||||||
|
<tr key={`${item.name}:${model.name}`} style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
|
<td className="px-4 py-2 font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-accent)", opacity: 0.75 }}>{model.name}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatNumber(model.calls)}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(model.total_tokens)}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatUSD(model.quota / 500000)}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{modelShare.toFixed(1)}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -127,41 +252,71 @@ export default function DetailPage() {
|
|||||||
<TrendChart data={trends} />
|
<TrendChart data={trends} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{breakdownItems.length > 0 && (
|
{(modelBreakdownItems.length > 0 || showTokenTab) && (
|
||||||
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass overflow-hidden">
|
<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>
|
<div className="flex items-center justify-between gap-3 px-5 pt-5">
|
||||||
<table className="w-full text-sm mt-3">
|
<h2 className="text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>
|
||||||
<thead>
|
{activeBreakdownTab === "tokens" ? t("detail.tokenDist") : breakdownLabel}
|
||||||
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
</h2>
|
||||||
{breakdownColumns.map((col) => {
|
{showTokenTab && (
|
||||||
const key = col.key;
|
<div className="flex gap-1 rounded-lg p-1" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||||
return (
|
{([
|
||||||
<th key={col.label}
|
["models", t("detail.modelDist")],
|
||||||
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
|
["tokens", t("detail.tokenDist")],
|
||||||
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
] as const).map(([value, label]) => (
|
||||||
onClick={key ? () => handleBreakdownSort(key) : undefined}
|
<button
|
||||||
>
|
key={value}
|
||||||
{key ? (
|
type="button"
|
||||||
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
|
onClick={() => setBreakdownTab(value)}
|
||||||
{col.label} {renderBreakdownSortIcon(key)}
|
className="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
</span>
|
style={{
|
||||||
) : col.label}
|
background: activeBreakdownTab === value ? "var(--btn-active-bg)" : "transparent",
|
||||||
</th>
|
color: activeBreakdownTab === value ? "var(--text-accent)" : "var(--text-muted)",
|
||||||
);
|
border: activeBreakdownTab === value ? "1px solid var(--surface-border)" : "1px solid transparent",
|
||||||
})}
|
}}
|
||||||
</tr>
|
>
|
||||||
</thead>
|
{label}
|
||||||
<tbody>
|
</button>
|
||||||
{sortedBreakdownItems.map((item) => (
|
))}
|
||||||
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
{activeBreakdownTab === "tokens" ? (
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
|
renderTokenDistribution()
|
||||||
|
) : sortedBreakdownItems.length > 0 ? (
|
||||||
|
<table className="w-full text-sm mt-3">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
|
||||||
|
{breakdownColumns.map((col) => {
|
||||||
|
const key = col.key;
|
||||||
|
return (
|
||||||
|
<th key={col.label}
|
||||||
|
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
|
||||||
|
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||||
|
onClick={key ? () => handleBreakdownSort(key) : undefined}
|
||||||
|
>
|
||||||
|
{key ? (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
|
||||||
|
{col.label} {renderBreakdownSortIcon(key)}
|
||||||
|
</span>
|
||||||
|
) : col.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{sortedBreakdownItems.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>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : renderEmptyBreakdown()}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
10
lib/i18n.tsx
10
lib/i18n.tsx
@@ -89,6 +89,11 @@ const translations = {
|
|||||||
"detail.trend": "使用趋势",
|
"detail.trend": "使用趋势",
|
||||||
"detail.modelDist": "模型分布",
|
"detail.modelDist": "模型分布",
|
||||||
"detail.userDist": "用户分布",
|
"detail.userDist": "用户分布",
|
||||||
|
"detail.tokenDist": "令牌分布",
|
||||||
|
"detail.primaryModels": "主要模型",
|
||||||
|
"detail.unnamedToken": "未命名令牌",
|
||||||
|
"detail.expandToken": "展开令牌",
|
||||||
|
"detail.collapseToken": "收起令牌",
|
||||||
// theme
|
// theme
|
||||||
"theme.light": "浅色",
|
"theme.light": "浅色",
|
||||||
"theme.dark": "深色",
|
"theme.dark": "深色",
|
||||||
@@ -210,6 +215,11 @@ const translations = {
|
|||||||
"detail.trend": "Usage Trend",
|
"detail.trend": "Usage Trend",
|
||||||
"detail.modelDist": "Model Distribution",
|
"detail.modelDist": "Model Distribution",
|
||||||
"detail.userDist": "User Distribution",
|
"detail.userDist": "User Distribution",
|
||||||
|
"detail.tokenDist": "Token Distribution",
|
||||||
|
"detail.primaryModels": "Primary Models",
|
||||||
|
"detail.unnamedToken": "Unnamed Token",
|
||||||
|
"detail.expandToken": "Expand token",
|
||||||
|
"detail.collapseToken": "Collapse token",
|
||||||
"theme.light": "Light",
|
"theme.light": "Light",
|
||||||
"theme.dark": "Dark",
|
"theme.dark": "Dark",
|
||||||
"theme.system": "System",
|
"theme.system": "System",
|
||||||
|
|||||||
Reference in New Issue
Block a user