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>
|
</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 items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium">{selectedImage.alt}</p>
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<span className="font-medium text-foreground">
|
||||||
{formatBeijingTime(selectedImage.createdAt)}
|
模型 ID: {selectedImage.modelId || "未知模型"}
|
||||||
</p>
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatBeijingTime(selectedImage.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}
|
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";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
|
||||||
type RuntimeModelRegistryRow = {
|
type RuntimeModelRegistryRow = {
|
||||||
|
id: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
@ -19,11 +20,57 @@ type RuntimeModelRegistryRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeModelMeta = {
|
type RuntimeModelMeta = {
|
||||||
|
canonicalModelId: string | null;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
providerType: 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) {
|
function scoreRuntimeModelCandidate(row: RuntimeModelRegistryRow) {
|
||||||
return (
|
return (
|
||||||
(row.isEnabled ? 100 : 0) +
|
(row.isEnabled ? 100 : 0) +
|
||||||
@ -40,14 +87,19 @@ function buildRuntimeModelRegistry(rows: RuntimeModelRegistryRow[]) {
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const score = scoreRuntimeModelCandidate(row);
|
const score = scoreRuntimeModelCandidate(row);
|
||||||
const current = registry.get(row.modelId);
|
const meta = {
|
||||||
if (current && current.score >= score) continue;
|
|
||||||
registry.set(row.modelId, {
|
|
||||||
score,
|
score,
|
||||||
|
canonicalModelId: row.modelId,
|
||||||
modelName: row.displayName || null,
|
modelName: row.displayName || null,
|
||||||
providerName: row.providerName || null,
|
providerName: row.providerName || null,
|
||||||
providerType: row.providerType || 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;
|
return registry;
|
||||||
@ -59,6 +111,7 @@ function resolveRuntimeModelMeta(
|
|||||||
): RuntimeModelMeta {
|
): RuntimeModelMeta {
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
return {
|
return {
|
||||||
|
canonicalModelId: null,
|
||||||
modelName: null,
|
modelName: null,
|
||||||
providerName: null,
|
providerName: null,
|
||||||
providerType: null,
|
providerType: null,
|
||||||
@ -67,12 +120,110 @@ function resolveRuntimeModelMeta(
|
|||||||
|
|
||||||
const resolved = registry.get(modelId);
|
const resolved = registry.get(modelId);
|
||||||
return {
|
return {
|
||||||
|
canonicalModelId: resolved?.canonicalModelId || null,
|
||||||
modelName: resolved?.modelName || null,
|
modelName: resolved?.modelName || null,
|
||||||
providerName: resolved?.providerName || null,
|
providerName: resolved?.providerName || null,
|
||||||
providerType: resolved?.providerType || 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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -129,7 +280,7 @@ export async function GET(request: NextRequest) {
|
|||||||
.orderBy(sql`date(${generatedImage.createdAt})`);
|
.orderBy(sql`date(${generatedImage.createdAt})`);
|
||||||
|
|
||||||
// 4. 按图片尺寸统计
|
// 4. 按图片尺寸统计
|
||||||
const sizeStats = await db
|
const rawSizeStats = await db
|
||||||
.select({
|
.select({
|
||||||
imageSize: generatedImage.imageSize,
|
imageSize: generatedImage.imageSize,
|
||||||
count: sql<number>`count(*)::int`,
|
count: sql<number>`count(*)::int`,
|
||||||
@ -137,9 +288,19 @@ export async function GET(request: NextRequest) {
|
|||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.groupBy(generatedImage.imageSize)
|
.groupBy(generatedImage.imageSize)
|
||||||
.orderBy(desc(sql`count(*)`));
|
.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
|
const runtimeModelRows = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: aiModel.id,
|
||||||
modelId: aiModel.modelId,
|
modelId: aiModel.modelId,
|
||||||
displayName: aiModel.displayName,
|
displayName: aiModel.displayName,
|
||||||
providerName: aiProvider.name,
|
providerName: aiProvider.name,
|
||||||
@ -158,26 +319,32 @@ export async function GET(request: NextRequest) {
|
|||||||
const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows);
|
const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows);
|
||||||
|
|
||||||
// 5. 按模型统计
|
// 5. 按模型统计
|
||||||
const aggregatedModelStats = await db
|
const rawModelUsageRows = await db
|
||||||
.select({
|
.select({
|
||||||
modelId: generatedImage.modelId,
|
modelId: generatedImage.modelId,
|
||||||
|
userId: generatedImage.userId,
|
||||||
count: sql<number>`count(*)::int`,
|
count: sql<number>`count(*)::int`,
|
||||||
userCount: sql<number>`count(distinct ${generatedImage.userId})::int`,
|
|
||||||
})
|
})
|
||||||
.from(generatedImage)
|
.from(generatedImage)
|
||||||
.groupBy(generatedImage.modelId)
|
.groupBy(generatedImage.modelId, generatedImage.userId);
|
||||||
.orderBy(desc(sql`count(*)`));
|
|
||||||
|
|
||||||
const modelStats = aggregatedModelStats.slice(0, 10).map((stat) => ({
|
const canonicalModelStats = buildCanonicalModelUsage(
|
||||||
|
rawModelUsageRows,
|
||||||
|
runtimeModelRegistry
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelStats = canonicalModelStats.slice(0, 10).map((stat) => ({
|
||||||
modelId: stat.modelId,
|
modelId: stat.modelId,
|
||||||
count: stat.count,
|
count: stat.count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modelDetailStats = aggregatedModelStats.slice(0, 20).map((stat) => ({
|
const modelDetailStats = canonicalModelStats.slice(0, 20).map((stat) => ({
|
||||||
modelId: stat.modelId,
|
modelId: stat.modelId,
|
||||||
count: stat.count,
|
count: stat.count,
|
||||||
userCount: stat.userCount,
|
userCount: stat.userCount,
|
||||||
...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId),
|
modelName: stat.modelName,
|
||||||
|
providerName: stat.providerName,
|
||||||
|
providerType: stat.providerType,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 5.2 按提供商类型统计
|
// 5.2 按提供商类型统计
|
||||||
@ -189,12 +356,11 @@ export async function GET(request: NextRequest) {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
for (const stat of aggregatedModelStats) {
|
for (const stat of canonicalModelStats) {
|
||||||
const meta = resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId);
|
const key = `${stat.providerType || "unknown"}::${stat.providerName || "unknown"}`;
|
||||||
const key = `${meta.providerType || "unknown"}::${meta.providerName || "unknown"}`;
|
|
||||||
const current = providerTypeStatsMap.get(key) || {
|
const current = providerTypeStatsMap.get(key) || {
|
||||||
providerType: meta.providerType,
|
providerType: stat.providerType,
|
||||||
providerName: meta.providerName,
|
providerName: stat.providerName,
|
||||||
count: 0,
|
count: 0,
|
||||||
};
|
};
|
||||||
current.count += stat.count;
|
current.count += stat.count;
|
||||||
@ -215,12 +381,10 @@ export async function GET(request: NextRequest) {
|
|||||||
.where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`)
|
.where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`)
|
||||||
.groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId)
|
.groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId)
|
||||||
.orderBy(sql`date(${generatedImage.createdAt})`);
|
.orderBy(sql`date(${generatedImage.createdAt})`);
|
||||||
const modelTrendStats = rawModelTrendStats.map((stat) => ({
|
const modelTrendStats = buildCanonicalModelTrend(
|
||||||
date: stat.date,
|
rawModelTrendStats,
|
||||||
modelId: stat.modelId,
|
runtimeModelRegistry
|
||||||
count: stat.count,
|
);
|
||||||
...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 6. 按月份统计
|
// 6. 按月份统计
|
||||||
const monthlyStats = await db
|
const monthlyStats = await db
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { User } from "next-auth";
|
import type { User } from "next-auth";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { TopNavbar } from "@/components/top-navbar";
|
import { TopNavbar } from "@/components/top-navbar";
|
||||||
|
|
||||||
type CustomLink = {
|
type CustomLink = {
|
||||||
@ -61,27 +62,39 @@ export function ChatShellChrome({
|
|||||||
agentEnabled,
|
agentEnabled,
|
||||||
videoCanvasEnabled,
|
videoCanvasEnabled,
|
||||||
}: ChatShellChromeProps) {
|
}: ChatShellChromeProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const hideSidebarChrome =
|
||||||
|
pathname === "/gallery" || pathname.startsWith("/gallery/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopNavbar
|
{hideSidebarChrome ? null : (
|
||||||
agentEnabled={agentEnabled}
|
<TopNavbar
|
||||||
customLinks={customLinks}
|
agentEnabled={agentEnabled}
|
||||||
isAdmin={isAdmin}
|
customLinks={customLinks}
|
||||||
productPlanningEnabled={productPlanningEnabled}
|
isAdmin={isAdmin}
|
||||||
rankingEnabled={rankingEnabled}
|
productPlanningEnabled={productPlanningEnabled}
|
||||||
settingsHref={settingsHref}
|
rankingEnabled={rankingEnabled}
|
||||||
siteLogo={siteLogo}
|
settingsHref={settingsHref}
|
||||||
siteLogoDark={siteLogoDark}
|
siteLogo={siteLogo}
|
||||||
siteName={siteName}
|
siteLogoDark={siteLogoDark}
|
||||||
toolbox1Enabled={toolbox1Enabled}
|
siteName={siteName}
|
||||||
toolbox1Name={toolbox1Name}
|
toolbox1Enabled={toolbox1Enabled}
|
||||||
toolbox2Enabled={toolbox2Enabled}
|
toolbox1Name={toolbox1Name}
|
||||||
toolbox2Name={toolbox2Name}
|
toolbox2Enabled={toolbox2Enabled}
|
||||||
tutorialEnabled={tutorialEnabled}
|
toolbox2Name={toolbox2Name}
|
||||||
tutorialVideos={tutorialVideos}
|
tutorialEnabled={tutorialEnabled}
|
||||||
videoCanvasEnabled={videoCanvasEnabled}
|
tutorialVideos={tutorialVideos}
|
||||||
/>
|
videoCanvasEnabled={videoCanvasEnabled}
|
||||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
/>
|
||||||
|
)}
|
||||||
|
<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>
|
<main className="h-full min-w-0 flex-1">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1529,9 +1529,8 @@ export function LovartHome({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setUpgradeDialogOpen(true)}
|
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">
|
<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" />
|
<Zap className="h-3 w-3 text-amber-300" />
|
||||||
{membershipData?.summary?.currentCredits ?? 0}
|
{membershipData?.summary?.currentCredits ?? 0}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user