refactor: polish analytics and gallery chrome
This commit is contained in:
parent
0861a0d87e
commit
180ee864d9
File diff suppressed because it is too large
Load Diff
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user