refactor: polish analytics and gallery chrome

This commit is contained in:
Your Name 2026-05-29 16:09:06 +08:00
parent 0861a0d87e
commit 180ee864d9
5 changed files with 775 additions and 571 deletions

File diff suppressed because it is too large Load Diff

View File

@ -538,13 +538,17 @@ export default function GalleryPage() {
/>
</div>
</div>
<div className="p-4 border-t bg-background">
<div className="border-t bg-background p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{selectedImage.alt}</p>
<p className="text-sm text-muted-foreground mt-1">
{formatBeijingTime(selectedImage.createdAt)}
</p>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-medium text-foreground">
模型 ID: {selectedImage.modelId || "未知模型"}
</span>
<span className="text-muted-foreground">
{formatBeijingTime(selectedImage.createdAt)}
</span>
</div>
</div>
<Button
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}

View File

@ -5,6 +5,7 @@ import { eq, sql, desc, and, gte, lt } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
type RuntimeModelRegistryRow = {
id: string;
modelId: string;
displayName: string;
providerName: string | null;
@ -19,11 +20,57 @@ type RuntimeModelRegistryRow = {
};
type RuntimeModelMeta = {
canonicalModelId: string | null;
modelName: string | null;
providerName: string | null;
providerType: string | null;
};
const ANALYTICS_SIZE_BUCKETS = ["4K", "3K", "2K", "1K"] as const;
function normalizeAnalyticsImageSizeBucket(value: string | null | undefined) {
const normalized = String(value || "")
.trim()
.toUpperCase();
if (!normalized) return "2K";
if (normalized.includes("4K")) return "4K";
if (normalized.includes("3K")) return "3K";
if (normalized.includes("2K")) return "2K";
if (normalized.includes("1K")) return "1K";
const sizeMatch = normalized.match(/(\d+(?:\.\d+)?)\s*K/);
if (sizeMatch) {
const sizeValue = Number(sizeMatch[1]);
if (sizeValue >= 3.5) return "4K";
if (sizeValue >= 2.5) return "3K";
if (sizeValue >= 1.5) return "2K";
return "1K";
}
const dimensionMatch = normalized.match(/(\d{3,5})\s*[X×*]\s*(\d{3,5})/);
if (dimensionMatch) {
const maxEdge = Math.max(
Number(dimensionMatch[1]),
Number(dimensionMatch[2])
);
if (maxEdge >= 3500) return "4K";
if (maxEdge >= 2500) return "3K";
if (maxEdge >= 1500) return "2K";
return "1K";
}
const numericMatch = normalized.match(/(\d{3,5})/);
if (numericMatch) {
const numericValue = Number(numericMatch[1]);
if (numericValue >= 3500) return "4K";
if (numericValue >= 2500) return "3K";
if (numericValue >= 1500) return "2K";
}
return "1K";
}
function scoreRuntimeModelCandidate(row: RuntimeModelRegistryRow) {
return (
(row.isEnabled ? 100 : 0) +
@ -40,14 +87,19 @@ function buildRuntimeModelRegistry(rows: RuntimeModelRegistryRow[]) {
for (const row of rows) {
const score = scoreRuntimeModelCandidate(row);
const current = registry.get(row.modelId);
if (current && current.score >= score) continue;
registry.set(row.modelId, {
const meta = {
score,
canonicalModelId: row.modelId,
modelName: row.displayName || null,
providerName: row.providerName || null,
providerType: row.providerType || null,
});
};
for (const key of [row.id, row.modelId]) {
const current = registry.get(key);
if (current && current.score >= score) continue;
registry.set(key, meta);
}
}
return registry;
@ -59,6 +111,7 @@ function resolveRuntimeModelMeta(
): RuntimeModelMeta {
if (!modelId) {
return {
canonicalModelId: null,
modelName: null,
providerName: null,
providerType: null,
@ -67,12 +120,110 @@ function resolveRuntimeModelMeta(
const resolved = registry.get(modelId);
return {
canonicalModelId: resolved?.canonicalModelId || null,
modelName: resolved?.modelName || null,
providerName: resolved?.providerName || null,
providerType: resolved?.providerType || null,
};
}
function buildCanonicalModelUsage(
rows: Array<{ modelId: string | null; userId: string; count: number }>,
registry: Map<string, RuntimeModelMeta & { score: number }>
) {
const usageMap = new Map<
string,
RuntimeModelMeta & {
modelId: string | null;
count: number;
userIds: Set<string>;
}
>();
for (const row of rows) {
const meta = resolveRuntimeModelMeta(registry, row.modelId);
const canonicalModelId = meta.canonicalModelId || row.modelId;
const key = canonicalModelId || row.modelId || "__unknown__";
const current = usageMap.get(key) || {
modelId: canonicalModelId,
canonicalModelId,
modelName: meta.modelName,
providerName: meta.providerName,
providerType: meta.providerType,
count: 0,
userIds: new Set<string>(),
};
current.count += row.count;
current.userIds.add(row.userId);
if (!current.modelName && meta.modelName) current.modelName = meta.modelName;
if (!current.providerName && meta.providerName) {
current.providerName = meta.providerName;
}
if (!current.providerType && meta.providerType) {
current.providerType = meta.providerType;
}
usageMap.set(key, current);
}
return Array.from(usageMap.values())
.map(({ userIds, canonicalModelId, ...item }) => ({
...item,
modelId: item.modelId || canonicalModelId,
userCount: userIds.size,
}))
.sort((left, right) => right.count - left.count);
}
function buildCanonicalModelTrend(
rows: Array<{ date: string; modelId: string | null; count: number }>,
registry: Map<string, RuntimeModelMeta & { score: number }>
) {
const trendMap = new Map<
string,
RuntimeModelMeta & {
date: string;
modelId: string | null;
count: number;
}
>();
for (const row of rows) {
const meta = resolveRuntimeModelMeta(registry, row.modelId);
const canonicalModelId = meta.canonicalModelId || row.modelId;
const key = `${row.date}::${canonicalModelId || row.modelId || "__unknown__"}`;
const current = trendMap.get(key) || {
date: row.date,
modelId: canonicalModelId,
canonicalModelId,
modelName: meta.modelName,
providerName: meta.providerName,
providerType: meta.providerType,
count: 0,
};
current.count += row.count;
if (!current.modelName && meta.modelName) current.modelName = meta.modelName;
if (!current.providerName && meta.providerName) {
current.providerName = meta.providerName;
}
if (!current.providerType && meta.providerType) {
current.providerType = meta.providerType;
}
trendMap.set(key, current);
}
return Array.from(trendMap.values())
.map(({ canonicalModelId, ...item }) => ({
...item,
modelId: item.modelId || canonicalModelId,
}))
.sort(
(left, right) =>
left.date.localeCompare(right.date) || right.count - left.count
);
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -129,7 +280,7 @@ export async function GET(request: NextRequest) {
.orderBy(sql`date(${generatedImage.createdAt})`);
// 4. 按图片尺寸统计
const sizeStats = await db
const rawSizeStats = await db
.select({
imageSize: generatedImage.imageSize,
count: sql<number>`count(*)::int`,
@ -137,9 +288,19 @@ export async function GET(request: NextRequest) {
.from(generatedImage)
.groupBy(generatedImage.imageSize)
.orderBy(desc(sql`count(*)`));
const sizeBucketMap = new Map<string, number>();
for (const stat of rawSizeStats) {
const bucket = normalizeAnalyticsImageSizeBucket(stat.imageSize);
sizeBucketMap.set(bucket, (sizeBucketMap.get(bucket) || 0) + stat.count);
}
const sizeStats = ANALYTICS_SIZE_BUCKETS.map((bucket) => ({
imageSize: bucket,
count: sizeBucketMap.get(bucket) || 0,
}));
const runtimeModelRows = await db
.select({
id: aiModel.id,
modelId: aiModel.modelId,
displayName: aiModel.displayName,
providerName: aiProvider.name,
@ -158,26 +319,32 @@ export async function GET(request: NextRequest) {
const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows);
// 5. 按模型统计
const aggregatedModelStats = await db
const rawModelUsageRows = await db
.select({
modelId: generatedImage.modelId,
userId: generatedImage.userId,
count: sql<number>`count(*)::int`,
userCount: sql<number>`count(distinct ${generatedImage.userId})::int`,
})
.from(generatedImage)
.groupBy(generatedImage.modelId)
.orderBy(desc(sql`count(*)`));
.groupBy(generatedImage.modelId, generatedImage.userId);
const modelStats = aggregatedModelStats.slice(0, 10).map((stat) => ({
const canonicalModelStats = buildCanonicalModelUsage(
rawModelUsageRows,
runtimeModelRegistry
);
const modelStats = canonicalModelStats.slice(0, 10).map((stat) => ({
modelId: stat.modelId,
count: stat.count,
}));
const modelDetailStats = aggregatedModelStats.slice(0, 20).map((stat) => ({
const modelDetailStats = canonicalModelStats.slice(0, 20).map((stat) => ({
modelId: stat.modelId,
count: stat.count,
userCount: stat.userCount,
...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId),
modelName: stat.modelName,
providerName: stat.providerName,
providerType: stat.providerType,
}));
// 5.2 按提供商类型统计
@ -189,12 +356,11 @@ export async function GET(request: NextRequest) {
count: number;
}
>();
for (const stat of aggregatedModelStats) {
const meta = resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId);
const key = `${meta.providerType || "unknown"}::${meta.providerName || "unknown"}`;
for (const stat of canonicalModelStats) {
const key = `${stat.providerType || "unknown"}::${stat.providerName || "unknown"}`;
const current = providerTypeStatsMap.get(key) || {
providerType: meta.providerType,
providerName: meta.providerName,
providerType: stat.providerType,
providerName: stat.providerName,
count: 0,
};
current.count += stat.count;
@ -215,12 +381,10 @@ export async function GET(request: NextRequest) {
.where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`)
.groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId)
.orderBy(sql`date(${generatedImage.createdAt})`);
const modelTrendStats = rawModelTrendStats.map((stat) => ({
date: stat.date,
modelId: stat.modelId,
count: stat.count,
...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId),
}));
const modelTrendStats = buildCanonicalModelTrend(
rawModelTrendStats,
runtimeModelRegistry
);
// 6. 按月份统计
const monthlyStats = await db

View File

@ -1,6 +1,7 @@
"use client";
import type { User } from "next-auth";
import { usePathname } from "next/navigation";
import { TopNavbar } from "@/components/top-navbar";
type CustomLink = {
@ -61,27 +62,39 @@ export function ChatShellChrome({
agentEnabled,
videoCanvasEnabled,
}: ChatShellChromeProps) {
const pathname = usePathname();
const hideSidebarChrome =
pathname === "/gallery" || pathname.startsWith("/gallery/");
return (
<>
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
isAdmin={isAdmin}
productPlanningEnabled={productPlanningEnabled}
rankingEnabled={rankingEnabled}
settingsHref={settingsHref}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
siteName={siteName}
toolbox1Enabled={toolbox1Enabled}
toolbox1Name={toolbox1Name}
toolbox2Enabled={toolbox2Enabled}
toolbox2Name={toolbox2Name}
tutorialEnabled={tutorialEnabled}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={videoCanvasEnabled}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
{hideSidebarChrome ? null : (
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
isAdmin={isAdmin}
productPlanningEnabled={productPlanningEnabled}
rankingEnabled={rankingEnabled}
settingsHref={settingsHref}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
siteName={siteName}
toolbox1Enabled={toolbox1Enabled}
toolbox1Name={toolbox1Name}
toolbox2Enabled={toolbox2Enabled}
toolbox2Name={toolbox2Name}
tutorialEnabled={tutorialEnabled}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={videoCanvasEnabled}
/>
)}
<div
className={
hideSidebarChrome
? "flex h-svh w-full overflow-hidden"
: "flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]"
}
>
<main className="h-full min-w-0 flex-1">{children}</main>
</div>
</>

View File

@ -1529,9 +1529,8 @@ export function LovartHome({
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2.5 text-[14px] text-white hover:bg-black hover:text-white"
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2 text-[14px] text-white hover:bg-black hover:text-white"
>
<span className="mr-1.5">升级</span>
<div className="inline-flex items-center gap-1 rounded-[9px] bg-white/10 px-1.5 py-0.5 text-xs text-white">
<Zap className="h-3 w-3 text-amber-300" />
{membershipData?.summary?.currentCredits ?? 0}