新增新版首页模式切换与会员升级配置体系
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-13 19:20:54 +08:00
parent 3db32eceb0
commit 9b1de1a9f3
10 changed files with 1873 additions and 83 deletions

View File

@ -67,6 +67,12 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -170,6 +176,10 @@ type SiteSettings = {
canvasEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
homeVariant: "classic" | "lovart";
surpriseCategories: string[];
membershipConfig: ReturnType<typeof normalizeMembershipConfig>;
membershipUserOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
};
type NavLink = {
@ -388,10 +398,27 @@ export function AdminSettingsClient() {
canvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
homeVariant: "classic",
surpriseCategories: ["全部", "惊喜"],
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
});
const [membershipConfigDraft, setMembershipConfigDraft] = useState(
JSON.stringify(defaultMembershipConfig, null, 2)
);
const [membershipOverridesDraft, setMembershipOverridesDraft] = useState(
JSON.stringify(defaultMembershipUserOverrides, null, 2)
);
useEffect(() => {
if (siteSettingsData) {
const normalizedMembershipConfig = normalizeMembershipConfig(
(siteSettingsData as any).membershipConfig
);
const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
(siteSettingsData as any).membershipUserOverrides
);
setSiteSettings({
siteName: siteSettingsData.siteName || "AI Chatbot",
siteLogo: siteSettingsData.siteLogo || "",
@ -416,7 +443,15 @@ export function AdminSettingsClient() {
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
homeVariant: siteSettingsData.homeVariant === "lovart" ? "lovart" : "classic",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories.map((item: any) => String(item).trim()).filter(Boolean)
: ["全部", "惊喜"],
membershipConfig: normalizedMembershipConfig,
membershipUserOverrides: normalizedMembershipOverrides,
});
setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2));
}
}, [siteSettingsData]);
@ -895,12 +930,42 @@ export function AdminSettingsClient() {
// --- 网站设置逻辑 ---
const handleSaveSiteSettings = async () => {
try {
let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>;
let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
try {
parsedMembershipConfig = normalizeMembershipConfig(
JSON.parse(membershipConfigDraft)
);
} catch {
toast.error("会员配置 JSON 格式错误,请检查后再保存");
return;
}
try {
parsedMembershipOverrides = normalizeMembershipUserOverrides(
JSON.parse(membershipOverridesDraft)
);
} catch {
toast.error("会员用户覆盖 JSON 格式错误,请检查后再保存");
return;
}
const payload = {
...siteSettings,
membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides,
};
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(siteSettings),
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("保存失败");
setSiteSettings(payload);
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
toast.success("网站设置保存成功");
mutateSiteSettings();
} catch (error) {
@ -2500,6 +2565,95 @@ export function AdminSettingsClient() {
设置新用户首次访问时的默认主题模式
</div>
</div>
<div className="space-y-2">
<Label htmlFor="homeVariant">首页展示模式</Label>
<Select
value={siteSettings.homeVariant}
onValueChange={(value: "classic" | "lovart") =>
setSiteSettings({ ...siteSettings, homeVariant: value })
}
>
<SelectTrigger id="homeVariant">
<SelectValue placeholder="选择首页模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">经典对话首页</SelectItem>
<SelectItem value="lovart">新版项目首页</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
新版项目首页会启用全新的项目入口、文件列表与精选展示。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="surpriseCategories">惊喜相册分类(逗号分隔)</Label>
<Input
id="surpriseCategories"
value={siteSettings.surpriseCategories.join(", ")}
onChange={(e) => {
const parsed = e.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const unique = Array.from(new Set(parsed));
setSiteSettings({
...siteSettings,
surpriseCategories: unique.length > 0 ? unique : ["全部", "惊喜"],
});
}}
placeholder="全部, 惊喜, 海报, 电商"
/>
<div className="text-xs text-muted-foreground">
新版首页“精选”模块会按这些分类显示筛选标签。
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">会员升级系统配置</Label>
<div className="text-xs text-muted-foreground mt-1">
支持配置套餐、FAQ、模型额度、用户套餐覆盖。保存后新版首页“升级”弹窗实时生效。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setMembershipConfigDraft(JSON.stringify(defaultMembershipConfig, null, 2));
setMembershipOverridesDraft(
JSON.stringify(defaultMembershipUserOverrides, null, 2)
);
toast.success("已恢复会员配置默认模板");
}}
>
恢复默认模板
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="membershipConfigDraft">会员配置 JSON</Label>
<Textarea
id="membershipConfigDraft"
value={membershipConfigDraft}
onChange={(e) => setMembershipConfigDraft(e.target.value)}
rows={12}
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="membershipOverridesDraft">用户套餐覆盖 JSON</Label>
<Textarea
id="membershipOverridesDraft"
value={membershipOverridesDraft}
onChange={(e) => setMembershipOverridesDraft(e.target.value)}
rows={7}
className="font-mono text-xs"
/>
<div className="text-xs text-muted-foreground">
可用字段:`userPlansById`(用户ID到planId)和 `userPlansByEmail`(邮箱到planId)。
</div>
</div>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>

View File

@ -1,12 +1,15 @@
import { cookies } from "next/headers";
import Script from "next/script";
import { Suspense } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { ChatShell } from "@/components/chat-shell";
import { DataStreamProvider } from "@/components/data-stream-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { auth } from "../(auth)/auth";
import { TopNavbar } from "@/components/top-navbar";
import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries";
import {
getNavLinks,
getSiteSettings,
getSystemSetting,
getTutorialVideos,
} from "@/lib/db/queries";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
@ -25,48 +28,51 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
const [session, cookieStore, siteSettings, navLinks, tutorialVideos] = await Promise.all([
auth(),
cookies(),
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
]);
const [session, cookieStore, siteSettings, navLinks, tutorialVideos, homeVariantSetting] =
await Promise.all([
auth(),
cookies(),
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
]);
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
const homeVariant =
homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<TopNavbar
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}
isAdmin={session?.user?.type === "admin"}
customLinks={navLinks.map(link => ({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
toolboxGroup: link.toolboxGroup || 1,
}))}
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideos={tutorialVideos.map(v => ({
id: v.id,
title: v.title,
videoUrl: v.videoUrl,
coverUrl: v.coverUrl,
description: v.description,
order: v.order,
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
<AppSidebar user={session?.user} />
<SidebarInset className="h-full">{children}</SidebarInset>
</div>
</SidebarProvider>
<ChatShell
user={session?.user}
isCollapsed={isCollapsed}
homeVariant={homeVariant}
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}
isAdmin={session?.user?.type === "admin"}
customLinks={navLinks.map((link) => ({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
toolboxGroup: link.toolboxGroup || 1,
}))}
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideos={tutorialVideos.map((v) => ({
id: v.id,
title: v.title,
videoUrl: v.videoUrl,
coverUrl: v.coverUrl,
description: v.description,
order: v.order,
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
>
{children}
</ChatShell>
);
}

View File

@ -3,9 +3,15 @@ import { redirect } from "next/navigation";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { LovartHome } from "@/components/home/lovart-home";
import { generateUUID } from "@/lib/utils";
import { auth } from "../(auth)/auth";
import { getDefaultAiModel, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import {
getDefaultAiModel,
getEnabledAiModelsWithProvider,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
export default function Page() {
@ -19,12 +25,41 @@ export default function Page() {
async function NewChatPage() {
const id = generateUUID();
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
const [session, cookieStore, siteSettings, homeVariantSetting, surpriseCategoriesSetting] =
await Promise.all([
auth(),
cookies(),
getSiteSettings(),
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined),
]);
// 如果用户未登录,重定向到登录页面
if (!session?.user) {
redirect("/login");
}
const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value)
? surpriseCategoriesSetting?.value
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: ["全部", "惊喜"];
if (homeVariant === "lovart") {
return (
<LovartHome
user={{
id: session.user.id,
email: session.user.email,
name: session.user.name,
}}
siteName={siteSettings.siteName || "AI Chatbot"}
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
surpriseCategories={surpriseCategories}
/>
);
}
const modelIdFromCookie = cookieStore.get("chat-model");

View File

@ -4,20 +4,95 @@ import { db } from "@/lib/db";
import { siteSettings, systemSettings } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
const HOME_VARIANT_KEY = "home_variant";
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
function normalizeHomeVariant(value: unknown): "classic" | "lovart" {
return value === "lovart" ? "lovart" : "classic";
}
function normalizeSurpriseCategories(value: unknown): string[] {
if (!Array.isArray(value)) {
return ["全部", "惊喜"];
}
const normalized = value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, 12);
if (normalized.length === 0) {
return ["全部", "惊喜"];
}
const unique = Array.from(new Set(normalized));
if (!unique.includes("全部")) {
unique.unshift("全部");
}
return unique;
}
// GET /api/site-settings - 获取网站设置 (公开)
export async function GET() {
try {
const [settings] = await db.select().from(siteSettings).limit(1);
const [
settings,
notificationSetting,
homeVariantSetting,
surpriseCategoriesSetting,
membershipConfigSetting,
membershipOverridesSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, "login_notification"))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, HOME_VARIANT_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, HOME_SURPRISE_CATEGORIES_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, MEMBERSHIP_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
.limit(1)
.then(rows => rows[0]),
]);
// 获取系统通知
const [notificationSetting] = await db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, "login_notification"))
.limit(1);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
const homeVariant = normalizeHomeVariant(homeVariantSetting?.value);
const surpriseCategories = normalizeSurpriseCategories(surpriseCategoriesSetting?.value);
const membershipConfig = normalizeMembershipConfig(membershipConfigSetting?.value);
const membershipUserOverrides = normalizeMembershipUserOverrides(
membershipOverridesSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -44,11 +119,19 @@ export async function GET() {
canvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
homeVariant,
surpriseCategories,
membershipConfig,
membershipUserOverrides,
};
const response = NextResponse.json({
...baseSettings,
notification,
homeVariant,
surpriseCategories,
membershipConfig,
membershipUserOverrides,
});
// 禁用缓存,确保总是获取最新设置
@ -90,6 +173,10 @@ const updateSchema = z.object({
canvasEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
homeVariant: z.enum(["classic", "lovart"]).optional(),
surpriseCategories: z.array(z.string().trim().min(1).max(20)).max(12).optional(),
membershipConfig: z.unknown().optional(),
membershipUserOverrides: z.unknown().optional(),
});
// PUT /api/site-settings - 更新网站设置(仅管理员)
@ -107,6 +194,13 @@ export async function PUT(request: NextRequest) {
const body = await request.json();
const data = updateSchema.parse(body);
const {
homeVariant,
surpriseCategories,
membershipConfig,
membershipUserOverrides,
...siteSettingsPayload
} = data;
// 查找现有设置
const [existing] = await db.select().from(siteSettings).limit(1);
@ -116,47 +210,235 @@ export async function PUT(request: NextRequest) {
const [updated] = await db
.update(siteSettings)
.set({
...data,
...siteSettingsPayload,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.where(eq(siteSettings.id, existing.id))
.returning();
return NextResponse.json(updated);
if (homeVariant !== undefined) {
await db
.insert(systemSettings)
.values({
key: HOME_VARIANT_KEY,
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (surpriseCategories !== undefined) {
const normalized = normalizeSurpriseCategories(surpriseCategories);
await db
.insert(systemSettings)
.values({
key: HOME_SURPRISE_CATEGORIES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
.insert(systemSettings)
.values({
key: MEMBERSHIP_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipUserOverrides !== undefined) {
const normalized = normalizeMembershipUserOverrides(membershipUserOverrides);
await db
.insert(systemSettings)
.values({
key: MEMBERSHIP_USER_OVERRIDES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...updated,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
surpriseCategories:
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
: defaultMembershipConfig,
membershipUserOverrides:
membershipUserOverrides !== undefined
? normalizeMembershipUserOverrides(membershipUserOverrides)
: defaultMembershipUserOverrides,
});
} else {
// 创建新设置
const [created] = await db
.insert(siteSettings)
.values({
siteName: data.siteName || "AI Chatbot",
siteLogo: data.siteLogo,
siteLogoDark: data.siteLogoDark,
favicon: data.favicon,
showAutoModel: data.showAutoModel ?? true,
defaultTheme: data.defaultTheme ?? "system",
usageTimeEnabled: data.usageTimeEnabled ?? false,
usageTimeStart: data.usageTimeStart ?? "08:00",
usageTimeEnd: data.usageTimeEnd ?? "20:00",
usageTimeMessage: data.usageTimeMessage ?? "由壹点累了也是需要休息的",
bannerEnabled: data.bannerEnabled ?? true,
bannerText: data.bannerText ?? "升级会员, Nano Banana Pro 免费365天!",
bannerLink: data.bannerLink,
toolbox1Name: data.toolbox1Name ?? "工具箱",
toolbox1Enabled: data.toolbox1Enabled ?? true,
toolbox2Name: data.toolbox2Name ?? "工具箱2",
toolbox2Enabled: data.toolbox2Enabled ?? false,
tutorialVideoUrl: data.tutorialVideoUrl,
tutorialEnabled: data.tutorialEnabled ?? false,
rankingEnabled: data.rankingEnabled ?? true,
canvasEnabled: data.canvasEnabled ?? true,
canvasDefaultModelId: data.canvasDefaultModelId,
canvasAutoModeDefaultModelId: data.canvasAutoModeDefaultModelId,
siteName: siteSettingsPayload.siteName || "AI Chatbot",
siteLogo: siteSettingsPayload.siteLogo,
siteLogoDark: siteSettingsPayload.siteLogoDark,
favicon: siteSettingsPayload.favicon,
showAutoModel: siteSettingsPayload.showAutoModel ?? true,
defaultTheme: siteSettingsPayload.defaultTheme ?? "system",
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "升级会员, Nano Banana Pro 免费365天!",
bannerLink: siteSettingsPayload.bannerLink,
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
toolbox2Enabled: siteSettingsPayload.toolbox2Enabled ?? false,
tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl,
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
updatedBy: session.user.id,
})
.returning();
return NextResponse.json(created);
if (homeVariant !== undefined) {
await db
.insert(systemSettings)
.values({
key: HOME_VARIANT_KEY,
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: homeVariant,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (surpriseCategories !== undefined) {
const normalized = normalizeSurpriseCategories(surpriseCategories);
await db
.insert(systemSettings)
.values({
key: HOME_SURPRISE_CATEGORIES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
.insert(systemSettings)
.values({
key: MEMBERSHIP_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipUserOverrides !== undefined) {
const normalized = normalizeMembershipUserOverrides(membershipUserOverrides);
await db
.insert(systemSettings)
.values({
key: MEMBERSHIP_USER_OVERRIDES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...created,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
surpriseCategories:
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
: defaultMembershipConfig,
membershipUserOverrides:
membershipUserOverrides !== undefined
? normalizeMembershipUserOverrides(membershipUserOverrides)
: defaultMembershipUserOverrides,
});
}
} catch (error) {
console.error("[Site Settings API] Update error:", error);

View File

@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import {
checkUserImageQuota,
getUserMonthlyImageCount,
} from "@/lib/db/queries";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
getMembershipPlanById,
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
resolveMembershipPlanIdForUser,
} from "@/lib/membership/config";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [configSetting, overridesSetting, quota, totalImages] = await Promise.all([
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, MEMBERSHIP_CONFIG_KEY))
.limit(1)
.then((rows) => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
.limit(1)
.then((rows) => rows[0]),
checkUserImageQuota(session.user.id),
getUserMonthlyImageCount(session.user.id),
]);
const config = configSetting?.value
? normalizeMembershipConfig(configSetting.value)
: defaultMembershipConfig;
const overrides = overridesSetting?.value
? normalizeMembershipUserOverrides(overridesSetting.value)
: defaultMembershipUserOverrides;
const currentPlanId = resolveMembershipPlanIdForUser(
session.user.id,
session.user.email,
config,
overrides
);
const currentPlan = getMembershipPlanById(config, currentPlanId);
const currentCredits =
quota.remaining !== null ? Math.max(0, quota.remaining) : currentPlan.monthlyCredits;
return NextResponse.json({
config,
currentPlanId,
currentPlan,
summary: {
currentCredits,
dailyFreeCredits: currentPlan.dailyFreeCredits,
usedThisMonth: quota.used,
monthlyLimit: quota.limit,
remainingThisMonth: quota.remaining,
totalImages,
},
updatedAt: new Date().toISOString(),
});
} catch (error) {
console.error("[Membership Summary API] Error:", error);
return NextResponse.json(
{ error: "Failed to fetch membership summary" },
{ status: 500 }
);
}
}

View File

@ -23,6 +23,7 @@ import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
@ -210,6 +211,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
const sessionMenuRef = useRef<HTMLDivElement>(null);
// 🆕 标签悬停预览状态
@ -447,6 +449,81 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition);
}, []);
useEffect(() => {
pendingLaunchRef.current = null;
if (typeof window === "undefined") return;
try {
const storageKey = getCanvasLaunchStorageKey(canvasId);
const raw = sessionStorage.getItem(storageKey);
if (!raw) return;
sessionStorage.removeItem(storageKey);
const parsed = JSON.parse(raw) as CanvasLaunchPayload;
const normalizedAttachments = Array.isArray(parsed.attachments)
? parsed.attachments
.filter((item) => item?.url)
.map((item) => ({
url: String(item.url),
name: String(item.name || "参考图"),
contentType: String(item.contentType || "image/jpeg"),
}))
: [];
const normalizedPrompt = String(parsed.prompt || "").trim();
if (!normalizedPrompt && normalizedAttachments.length === 0) {
return;
}
setAttachments(normalizedAttachments as PickedObjectAttachment[]);
setInput(normalizedPrompt);
setTimeout(() => {
normalizedAttachments.forEach((attachment, index) => {
insertImageTagInEditor(attachment.url, attachment.name, index);
});
}, 50);
if (parsed.autoSend) {
pendingLaunchRef.current = {
prompt: normalizedPrompt,
attachments: normalizedAttachments,
autoSend: true,
createdAt: parsed.createdAt,
};
}
normalizedAttachments.forEach(async (attachment, index) => {
const dimensions = await getImageDimensions(attachment.url);
addNode({
type: "image",
position: {
x: -260 + index * 80,
y: -120 + (index % 2) * 40,
},
size: {
width: dimensions.width,
height: dimensions.height,
},
data: {
src: attachment.url,
status: "completed",
title: attachment.name || `参考图 ${index + 1}`,
prompt: normalizedPrompt || "",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
});
} catch (error) {
console.error("[CanvasChat] Failed to restore launch payload:", error);
}
}, [canvasId, addNode, insertImageTagInEditor]);
// 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签
useEffect(() => {
if (pendingChatAttachments.length > 0) {
@ -749,6 +826,48 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
toast.error(error.message || "生成失败");
},
});
useEffect(() => {
const launchPayload = pendingLaunchRef.current;
if (!launchPayload?.autoSend) return;
if (!selectedModelId || status !== "ready") return;
const launchAttachments = launchPayload.attachments || [];
const textContent =
launchPayload.prompt?.trim() ||
(launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : "");
if (!textContent && launchAttachments.length === 0) {
pendingLaunchRef.current = null;
return;
}
const parts: any[] = launchAttachments.map((attachment) => ({
type: "file" as const,
mediaType: attachment.contentType || "image/jpeg",
name: attachment.name || "参考图",
url: attachment.url,
}));
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
sendMessage({
role: "user",
parts,
});
pendingLaunchRef.current = null;
setInput("");
setAttachments([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [selectedModelId, status, sendMessage, clearSelection]);
// 当历史消息加载完成后,更新消息列表
const hasLoadedHistory = useRef(false);
@ -771,6 +890,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
isInitialLoad.current = true;
hiddenPromptRef.current = null;
autoContextRef.current = null;
pendingLaunchRef.current = null;
setMessages([]);
setInput("");
setAttachments([]);

92
components/chat-shell.tsx Normal file
View File

@ -0,0 +1,92 @@
"use client";
import { usePathname } from "next/navigation";
import type { User } from "next-auth";
import { AppSidebar } from "@/components/app-sidebar";
import { TopNavbar } from "@/components/top-navbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
type CustomLink = {
id: string;
name: string;
url: string;
order: number;
toolboxGroup: number;
};
type TutorialVideo = {
id: string;
title: string;
videoUrl: string;
coverUrl: string | null;
description: string | null;
order: number;
};
type ChatShellProps = {
children: React.ReactNode;
user?: User;
isCollapsed: boolean;
homeVariant: "classic" | "lovart";
siteName: string;
siteLogo?: string;
siteLogoDark?: string;
isAdmin: boolean;
customLinks: CustomLink[];
toolbox1Name: string;
toolbox1Enabled: boolean;
toolbox2Name: string;
toolbox2Enabled: boolean;
tutorialVideos: TutorialVideo[];
tutorialEnabled: boolean;
rankingEnabled: boolean;
};
export function ChatShell({
children,
user,
isCollapsed,
homeVariant,
siteName,
siteLogo,
siteLogoDark,
isAdmin,
customLinks,
toolbox1Name,
toolbox1Enabled,
toolbox2Name,
toolbox2Enabled,
tutorialVideos,
tutorialEnabled,
rankingEnabled,
}: ChatShellProps) {
const pathname = usePathname();
const isLovartHome = pathname === "/" && homeVariant === "lovart";
if (isLovartHome) {
return <div className="h-svh w-full overflow-hidden">{children}</div>;
}
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<TopNavbar
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
isAdmin={isAdmin}
customLinks={customLinks}
toolbox1Name={toolbox1Name}
toolbox1Enabled={toolbox1Enabled}
toolbox2Name={toolbox2Name}
toolbox2Enabled={toolbox2Enabled}
tutorialVideos={tutorialVideos}
tutorialEnabled={tutorialEnabled}
rankingEnabled={rankingEnabled}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
<AppSidebar user={user} />
<SidebarInset className="h-full">{children}</SidebarInset>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,693 @@
"use client";
import { Fragment, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/navigation";
import {
Bell,
ChevronDown,
Folder,
Home,
Plus,
Send,
Sparkles,
Upload,
User,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
type CanvasLaunchAttachment,
getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch";
import type { MembershipConfig } from "@/lib/membership/config";
type HomeUser = {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
};
type CanvasProject = {
id: string;
title: string;
thumbnail?: string | null;
createdAt?: string;
updatedAt?: string;
};
type FeaturedImage = {
id: string;
thumbnailUrl: string;
originalUrl: string;
prompt?: string | null;
};
type MembershipSummaryResponse = {
config: MembershipConfig;
currentPlanId: string;
currentPlan: {
id: string;
name: string;
monthlyPrice: number;
annualPrice: number;
monthlyCredits: number;
dailyFreeCredits: number;
features: string[];
badge?: string;
highlighted?: boolean;
};
summary: {
currentCredits: number;
dailyFreeCredits: number;
usedThisMonth: number;
monthlyLimit: number | null;
remainingThisMonth: number | null;
totalImages: number;
};
};
type LovartHomeProps = {
user: HomeUser;
siteName: string;
siteLogo?: string | null;
surpriseCategories: string[];
};
type UploadFileItem = {
id: string;
file: File;
previewUrl: string;
};
function formatRelativeTime(value?: string) {
if (!value) return "刚刚更新";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "刚刚更新";
const diffMs = Date.now() - date.getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < hour) return `${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
if (diffMs < day) return `${Math.round(diffMs / hour)} 小时前`;
return `${Math.round(diffMs / day)} 天前`;
}
function formatPrice(currency: string, value: number) {
if (value <= 0) return "免费";
return `${currency}${value}`;
}
export function LovartHome({
user,
siteName,
siteLogo,
surpriseCategories,
}: LovartHomeProps) {
const router = useRouter();
const [prompt, setPrompt] = useState("");
const [files, setFiles] = useState<UploadFileItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeCategory, setActiveCategory] = useState("全部");
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
const moduleBRef = useRef<HTMLDivElement | null>(null);
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
success: boolean;
data: CanvasProject[];
}>("/api/canvas", fetcher);
const { data: featuredData } = useSWR<{
images: FeaturedImage[];
}>("/api/featured?page=1&pageSize=18", fetcher);
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
const currentPlan = membershipData?.currentPlan;
const categoryTabs = useMemo(() => {
const base = surpriseCategories.length > 0 ? surpriseCategories : ["全部", "惊喜"];
if (!base.includes("全部")) {
return ["全部", ...base];
}
return base;
}, [surpriseCategories]);
const filteredFeatured = useMemo(() => {
const images = featuredData?.images || [];
if (activeCategory === "全部") return images;
const keyword = activeCategory.toLowerCase();
return images.filter((item) =>
String(item.prompt || "").toLowerCase().includes(keyword)
);
}, [featuredData?.images, activeCategory]);
const groupedModelRows = useMemo(() => {
const rows = membershipConfig?.modelRows || [];
return rows.reduce<Record<string, typeof rows>>((acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
}, {});
}, [membershipConfig?.modelRows]);
const handleAddFiles = (targetFiles: FileList | null) => {
if (!targetFiles || targetFiles.length === 0) return;
const accepted = Array.from(targetFiles)
.filter((file) => file.type.startsWith("image/"))
.slice(0, 6);
if (accepted.length === 0) {
toast.error("请上传图片文件");
return;
}
const next = accepted.map((file) => ({
id: `${file.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
file,
previewUrl: URL.createObjectURL(file),
}));
setFiles((prev) => [...prev, ...next].slice(0, 6));
};
const removeFile = (id: string) => {
setFiles((prev) => {
const target = prev.find((item) => item.id === id);
if (target) {
URL.revokeObjectURL(target.previewUrl);
}
return prev.filter((item) => item.id !== id);
});
};
const uploadSelectedFiles = async (): Promise<CanvasLaunchAttachment[]> => {
const result: CanvasLaunchAttachment[] = [];
for (const item of files) {
const formData = new FormData();
formData.append("file", item.file);
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
result.push({
url: data.url,
name: item.file.name,
contentType: item.file.type || "image/jpeg",
});
}
return result;
};
const createCanvasFromComposer = async () => {
if (!prompt.trim() && files.length === 0) return;
setIsSubmitting(true);
try {
const uploadedAttachments = await uploadSelectedFiles();
const title = prompt.trim()
? prompt.trim().slice(0, 24)
: `新建项目 ${new Date().toLocaleDateString("zh-CN")}`;
const canvasResponse = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const canvasResult = await canvasResponse.json();
if (!canvasResponse.ok || !canvasResult?.data?.id) {
throw new Error(canvasResult?.error || "创建项目失败");
}
const canvasId: string = canvasResult.data.id;
if (typeof window !== "undefined") {
sessionStorage.setItem(
getCanvasLaunchStorageKey(canvasId),
JSON.stringify({
prompt:
prompt.trim() ||
(uploadedAttachments.length > 0
? "请基于这些图片继续生成设计方案"
: ""),
attachments: uploadedAttachments,
autoSend: true,
createdAt: Date.now(),
})
);
}
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
setFiles([]);
setPrompt("");
await mutateCanvas();
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
} finally {
setIsSubmitting(false);
}
};
const openNewCanvas = () => {
router.push("/canvas?new=1");
};
return (
<div className="h-svh w-full overflow-y-auto bg-[radial-gradient(circle_at_10%_20%,#f0f5ff_0%,#f8fafc_48%,#ecfeff_100%)]">
<aside className="fixed left-0 top-0 z-30 flex h-svh w-16 flex-col items-center border-r border-slate-200/70 bg-white/80 py-4 backdrop-blur-xl">
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-xl" onClick={openNewCanvas}>
<Plus className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" className="mt-2 h-10 w-10 rounded-xl" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
<Home className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="mt-2 h-10 w-10 rounded-xl"
onClick={() => moduleBRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}
>
<Folder className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" className="mt-2 h-10 w-10 rounded-xl" onClick={() => toast.info("个人首页稍后开放")}>
<User className="h-5 w-5" />
</Button>
</aside>
<main className="ml-16 px-6 pb-8 pt-6">
<header className="mb-6 flex items-center justify-between rounded-2xl border border-slate-200/80 bg-white/70 px-5 py-3 backdrop-blur">
<div className="flex items-center gap-3">
{siteLogo ? (
<img src={siteLogo} alt={siteName} className="h-8 w-8 rounded-lg object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500 text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<div>
<div className="text-sm font-medium text-slate-900">{siteName}</div>
<div className="text-xs text-slate-500">新首页 · 项目中心</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-xl">
<Bell className="h-4 w-4" />
</Button>
<HoverCard openDelay={100} closeDelay={120}>
<HoverCardTrigger asChild>
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
className="h-11 rounded-2xl bg-slate-900 px-3 text-white hover:bg-slate-800"
>
<span className="mr-2 text-base">升级</span>
<div className="inline-flex items-center gap-1 rounded-xl bg-white/10 px-2 py-1 text-sm">
<Zap className="h-3.5 w-3.5 text-amber-300" />
{membershipData?.summary?.currentCredits ?? 0}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 rounded-2xl border-slate-200 p-0">
<div className="p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-[32px] font-semibold leading-none text-slate-900">
{currentPlan?.name || "Free"}
</div>
<Button
size="sm"
className="rounded-xl bg-slate-800 px-4 text-white hover:bg-slate-700"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="space-y-2 border-t border-slate-200 pt-3 text-sm text-slate-700">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2">
<Sparkles className="h-4 w-4" />
积分
</span>
<span className="font-medium">{membershipData?.summary?.currentCredits ?? 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2">
<Upload className="h-4 w-4" />
每日免费积分
</span>
<span className="font-medium">{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
</div>
</div>
<button
type="button"
className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-slate-900"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
<div className="flex h-9 min-w-9 items-center justify-center rounded-xl bg-slate-900 px-2 text-xs text-white">
{(user.name || user.email || "U").slice(0, 1).toUpperCase()}
</div>
</div>
</header>
<section className="mb-6 rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm">
<div className="mb-3 text-base font-semibold text-slate-900">开始一个新项目</div>
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="输入你的创作需求,发送后会自动进入新画布并开始处理"
className="min-h-[110px] resize-none rounded-2xl border-slate-200 bg-slate-50"
/>
{files.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{files.map((item) => (
<div key={item.id} className="group relative h-14 w-14 overflow-hidden rounded-lg border border-slate-200">
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
<button
type="button"
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-xs text-white group-hover:flex"
onClick={() => removeFile(item.id)}
>
x
</button>
</div>
))}
</div>
)}
<div className="mt-4 flex items-center gap-3">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
<Upload className="h-4 w-4" />
添加附件
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<Button
onClick={createCanvasFromComposer}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-10 rounded-xl bg-slate-900 px-5 text-white hover:bg-slate-800"
>
<Send className="mr-2 h-4 w-4" />
{isSubmitting ? "正在创建..." : "发送并创建画布"}
</Button>
</div>
</section>
<section ref={moduleBRef} className="mb-6 rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">项目文件</h2>
<Button variant="outline" className="h-9 rounded-xl" onClick={openNewCanvas}>
<Plus className="mr-1 h-4 w-4" />
新建画布
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{(canvasData?.data || []).slice(0, 12).map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/canvas/${item.id}`)}
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 p-3 text-left transition hover:-translate-y-0.5 hover:border-slate-300 hover:bg-white"
>
<div className="h-14 w-20 overflow-hidden rounded-lg bg-slate-200">
{item.thumbnail ? (
<img src={item.thumbnail} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-500">Canvas</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-900">{item.title || "未命名画布"}</div>
<div className="mt-1 text-xs text-slate-500">{formatRelativeTime(item.updatedAt || item.createdAt)}</div>
</div>
</button>
))}
{(canvasData?.data || []).length === 0 && (
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
还没有画布文件,点击“新建画布”开始。
</div>
)}
</div>
</section>
<section className="rounded-3xl border border-slate-200/80 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">精选灵感</h2>
<div className="text-xs text-slate-500">系统设置可配置分类</div>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{categoryTabs.map((category) => (
<Button
key={category}
variant={activeCategory === category ? "default" : "outline"}
className="h-8 rounded-full px-4 text-xs"
onClick={() => setActiveCategory(category)}
>
{category}
</Button>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{filteredFeatured.slice(0, 12).map((item) => (
<button
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
className="group overflow-hidden rounded-xl border border-slate-200 bg-slate-50 text-left"
>
<img
src={item.thumbnailUrl || item.originalUrl}
alt={item.prompt || "精选图片"}
className="h-28 w-full object-cover transition duration-200 group-hover:scale-105"
/>
<div className="line-clamp-2 px-2 py-2 text-xs text-slate-600">
{item.prompt || "精选灵感图"}
</div>
</button>
))}
{filteredFeatured.length === 0 && (
<div className="col-span-full rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
当前分类还没有数据,先切换到“全部”查看。
</div>
)}
</div>
</section>
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[1180px] overflow-hidden rounded-3xl border-slate-200 bg-white p-0">
<div className="flex h-full max-h-[90vh] flex-col">
<DialogHeader className="border-b border-slate-100 px-6 py-5">
<DialogTitle className="text-center text-3xl font-bold text-slate-900">
升级您的套餐
</DialogTitle>
<div className="mt-3 flex items-center justify-center gap-2">
<Button
variant={billingCycle === "monthly" ? "default" : "outline"}
className="h-8 rounded-full px-4 text-xs"
onClick={() => setBillingCycle("monthly")}
>
月付
</Button>
<Button
variant={billingCycle === "annual" ? "default" : "outline"}
className="h-8 rounded-full px-4 text-xs"
onClick={() => setBillingCycle("annual")}
>
年付
</Button>
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">
{membershipConfig?.annualDiscountLabel || "限时优惠"}
</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-5 pb-6 pt-4">
{membershipPlans.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
{membershipPlans.map((plan) => {
const isCurrent = currentPlan?.id === plan.id;
const annualMonthlyPrice =
plan.annualPrice > 0 ? Number((plan.annualPrice / 12).toFixed(2)) : 0;
return (
<div
key={plan.id}
className={`rounded-2xl border p-4 ${
plan.highlighted
? "border-indigo-400 bg-indigo-50/40"
: "border-slate-200 bg-white"
}`}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xl font-semibold text-slate-900">{plan.name}</h3>
{plan.badge ? (
<span className="rounded-full bg-indigo-100 px-2 py-1 text-[10px] text-indigo-700">
{plan.badge}
</span>
) : null}
</div>
<div className="mb-3">
{plan.monthlyPrice <= 0 ? (
<div className="text-3xl font-bold text-slate-900">免费</div>
) : billingCycle === "monthly" ? (
<div className="text-3xl font-bold text-slate-900">
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
<span className="ml-1 text-sm font-normal text-slate-500">/月</span>
</div>
) : (
<div>
<div className="text-3xl font-bold text-slate-900">
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
<span className="ml-1 text-sm font-normal text-slate-500">/年</span>
</div>
<div className="mt-1 text-xs text-slate-500">
折合{" "}
{formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}
/月
</div>
</div>
)}
</div>
<div className="mb-3 text-sm text-slate-600">
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
</div>
<Button
className="mb-3 h-9 w-full rounded-xl"
disabled={isCurrent}
onClick={() => {
if (isCurrent) return;
toast.info("支付能力后续接入,已预留套餐配置接口");
}}
>
{isCurrent ? "当前套餐" : "升级"}
</Button>
<ul className="space-y-1.5 text-xs text-slate-600">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-slate-400" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
) : (
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
会员配置暂未启用,请在系统设置中完善会员 JSON 配置。
</div>
)}
<section className="mt-8">
<h4 className="mb-3 text-center text-2xl font-semibold text-slate-900">常见问题</h4>
<div className="space-y-2">
{(membershipConfig?.faqs || []).map((faq) => (
<details
key={faq.question}
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700"
>
<summary className="cursor-pointer list-none font-medium">
{faq.question}
</summary>
<p className="mt-2 text-slate-600">{faq.answer}</p>
</details>
))}
</div>
</section>
<section className="mt-8">
<h4 className="mb-2 text-center text-2xl font-semibold text-slate-900">
模型积分消耗 & 每月生成数量
</h4>
<p className="mb-4 text-center text-xs text-slate-500">
以下表格基于系统设置中的会员配置,可直接在后台调整。
</p>
<div className="overflow-x-auto rounded-2xl border border-slate-200">
<table className="min-w-[860px] w-full text-sm">
<thead>
<tr className="bg-slate-50">
<th className="px-3 py-3 text-left font-semibold text-slate-700">模型</th>
{membershipPlans.map((plan) => (
<th key={plan.id} className="px-3 py-3 text-left font-semibold text-slate-700">
{plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(groupedModelRows).map(([category, rows]) => (
<Fragment key={category}>
<tr key={`${category}-head`} className="bg-slate-100/70">
<td
colSpan={membershipPlans.length + 1}
className="px-3 py-2 text-xs font-semibold text-slate-600"
>
{category}
</td>
</tr>
{rows.map((row) => (
<tr key={`${category}-${row.model}`} className="border-t border-slate-100">
<td className="px-3 py-2 text-slate-700">{row.model}</td>
{membershipPlans.map((plan) => (
<td key={`${row.model}-${plan.id}`} className="px-3 py-2 text-slate-600">
{row.values[plan.id] ?? "-"}
{row.values[plan.id] && row.values[plan.id] !== "-" ? row.unit : ""}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</section>
</div>
</div>
</DialogContent>
</Dialog>
</main>
</div>
);
}

16
lib/canvas-launch.ts Normal file
View File

@ -0,0 +1,16 @@
export interface CanvasLaunchAttachment {
url: string;
name: string;
contentType: string;
}
export interface CanvasLaunchPayload {
prompt: string;
attachments: CanvasLaunchAttachment[];
autoSend?: boolean;
createdAt?: number;
}
export function getCanvasLaunchStorageKey(canvasId: string): string {
return `canvas-home-launch:${canvasId}`;
}

306
lib/membership/config.ts Normal file
View File

@ -0,0 +1,306 @@
export type MembershipPlanId = string;
export interface MembershipPlanConfig {
id: MembershipPlanId;
name: string;
monthlyPrice: number;
annualPrice: number;
monthlyCredits: number;
dailyFreeCredits: number;
badge?: string;
highlighted?: boolean;
features: string[];
}
export interface MembershipFaqItem {
question: string;
answer: string;
}
export interface MembershipModelRow {
category: string;
model: string;
unit: string;
values: Record<string, string>;
}
export interface MembershipConfig {
enabled: boolean;
currency: string;
defaultPlanId: MembershipPlanId;
annualDiscountLabel: string;
plans: MembershipPlanConfig[];
faqs: MembershipFaqItem[];
modelRows: MembershipModelRow[];
}
export interface MembershipUserOverrides {
userPlansById: Record<string, MembershipPlanId>;
userPlansByEmail: Record<string, MembershipPlanId>;
}
export const defaultMembershipConfig: MembershipConfig = {
enabled: true,
currency: "$",
defaultPlanId: "free",
annualDiscountLabel: "限时 5 折优惠",
plans: [
{
id: "free",
name: "Free",
monthlyPrice: 0,
annualPrice: 0,
monthlyCredits: 0,
dailyFreeCredits: 0,
features: ["基础模型可用", "社区体验", "可商用"],
},
{
id: "starter",
name: "Starter",
monthlyPrice: 19,
annualPrice: 190,
monthlyCredits: 2000,
dailyFreeCredits: 100,
features: ["访问所有图片模型", "2 个并发任务", "基础充值折扣"],
},
{
id: "basic",
name: "Basic",
monthlyPrice: 32,
annualPrice: 320,
monthlyCredits: 3500,
dailyFreeCredits: 100,
features: ["访问所有图片模型", "4 个并发任务", "充值更优折扣"],
},
{
id: "pro",
name: "Pro",
monthlyPrice: 69,
annualPrice: 690,
monthlyCredits: 11000,
dailyFreeCredits: 100,
badge: "热门",
features: ["访问所有图片模型", "8 个并发任务", "优先队列与高级折扣"],
},
{
id: "ultimate",
name: "Ultimate",
monthlyPrice: 149,
annualPrice: 1490,
monthlyCredits: 27000,
dailyFreeCredits: 100,
badge: "旗舰版",
highlighted: true,
features: ["访问所有图片模型", "10 个并发任务", "最高折扣与极速队列"],
},
],
faqs: [
{
question: "积分是如何使用的?",
answer: "每次调用模型会按模型单价扣除积分,扣费规则可在下方模型消耗表查看。",
},
{
question: "积分会过期吗?",
answer: "订阅周期内发放的月度积分随周期重置,充值积分默认长期有效。",
},
{
question: "我的订阅会自动续费吗?",
answer: "是的,订阅默认自动续费。后续可在支付中心关闭自动续费。",
},
{
question: "可以给团队统一开通吗?",
answer: "支持团队版,后续会在支付页面开放团队席位配置与发票能力。",
},
],
modelRows: [
{
category: "图片模型",
model: "Nano Banana Pro 1k",
unit: "张",
values: { free: "-", starter: "200", basic: "350", pro: "1100", ultimate: "2700" },
},
{
category: "图片模型",
model: "Nano Banana Pro 2k",
unit: "张",
values: { free: "-", starter: "200", basic: "350", pro: "1100", ultimate: "2700" },
},
{
category: "图片模型",
model: "Seedream 4.0",
unit: "张",
values: { free: "-", starter: "1000", basic: "1750", pro: "5500", ultimate: "13500" },
},
{
category: "视频模型",
model: "Kling 3.0 Pro",
unit: "个",
values: { free: "-", starter: "40", basic: "70", pro: "220", ultimate: "540" },
},
{
category: "视频模型",
model: "Kling 2.5 Turbo",
unit: "个",
values: { free: "-", starter: "200", basic: "350", pro: "1100", ultimate: "2700" },
},
],
};
export const defaultMembershipUserOverrides: MembershipUserOverrides = {
userPlansById: {},
userPlansByEmail: {},
};
function toNonEmptyString(value: unknown, fallback = ""): string {
const normalized = String(value ?? "").trim();
return normalized || fallback;
}
function toNumber(value: unknown, fallback = 0): number {
const num = Number(value);
if (Number.isNaN(num) || num < 0) return fallback;
return num;
}
export function normalizeMembershipConfig(value: unknown): MembershipConfig {
if (!value || typeof value !== "object") {
return defaultMembershipConfig;
}
const raw = value as Partial<MembershipConfig>;
const fallbackPlans = defaultMembershipConfig.plans;
const plans = Array.isArray(raw.plans)
? raw.plans
.map((plan) => {
if (!plan || typeof plan !== "object") return null;
const typed = plan as Partial<MembershipPlanConfig>;
const id = toNonEmptyString(typed.id);
if (!id) return null;
return {
id,
name: toNonEmptyString(typed.name, id.toUpperCase()),
monthlyPrice: toNumber(typed.monthlyPrice, 0),
annualPrice: toNumber(typed.annualPrice, 0),
monthlyCredits: toNumber(typed.monthlyCredits, 0),
dailyFreeCredits: toNumber(typed.dailyFreeCredits, 0),
badge: toNonEmptyString(typed.badge, ""),
highlighted: Boolean(typed.highlighted),
features: Array.isArray(typed.features)
? typed.features.map((item) => toNonEmptyString(item)).filter(Boolean)
: [],
} satisfies MembershipPlanConfig;
})
.filter(Boolean) as MembershipPlanConfig[]
: [];
const normalizedPlans = plans.length > 0 ? plans : fallbackPlans;
const availablePlanIds = new Set(normalizedPlans.map((plan) => plan.id));
const faqs = Array.isArray(raw.faqs)
? raw.faqs
.map((item) => {
if (!item || typeof item !== "object") return null;
const typed = item as Partial<MembershipFaqItem>;
const question = toNonEmptyString(typed.question);
const answer = toNonEmptyString(typed.answer);
if (!question || !answer) return null;
return { question, answer };
})
.filter(Boolean) as MembershipFaqItem[]
: defaultMembershipConfig.faqs;
const modelRows = Array.isArray(raw.modelRows)
? raw.modelRows
.map((row) => {
if (!row || typeof row !== "object") return null;
const typed = row as Partial<MembershipModelRow>;
const model = toNonEmptyString(typed.model);
if (!model) return null;
const values: Record<string, string> = {};
if (typed.values && typeof typed.values === "object") {
Object.entries(typed.values).forEach(([key, val]) => {
values[key] = toNonEmptyString(val);
});
}
return {
category: toNonEmptyString(typed.category, "图片模型"),
model,
unit: toNonEmptyString(typed.unit, "张"),
values,
};
})
.filter(Boolean) as MembershipModelRow[]
: defaultMembershipConfig.modelRows;
const defaultPlanId = toNonEmptyString(raw.defaultPlanId, defaultMembershipConfig.defaultPlanId);
return {
enabled: raw.enabled !== false,
currency: toNonEmptyString(raw.currency, defaultMembershipConfig.currency),
defaultPlanId: availablePlanIds.has(defaultPlanId)
? defaultPlanId
: defaultMembershipConfig.defaultPlanId,
annualDiscountLabel: toNonEmptyString(
raw.annualDiscountLabel,
defaultMembershipConfig.annualDiscountLabel
),
plans: normalizedPlans,
faqs: faqs.length > 0 ? faqs : defaultMembershipConfig.faqs,
modelRows: modelRows.length > 0 ? modelRows : defaultMembershipConfig.modelRows,
};
}
export function normalizeMembershipUserOverrides(value: unknown): MembershipUserOverrides {
if (!value || typeof value !== "object") {
return defaultMembershipUserOverrides;
}
const raw = value as Partial<MembershipUserOverrides>;
const userPlansById: Record<string, MembershipPlanId> = {};
const userPlansByEmail: Record<string, MembershipPlanId> = {};
if (raw.userPlansById && typeof raw.userPlansById === "object") {
Object.entries(raw.userPlansById).forEach(([key, val]) => {
const id = toNonEmptyString(key);
const planId = toNonEmptyString(val);
if (id && planId) userPlansById[id] = planId;
});
}
if (raw.userPlansByEmail && typeof raw.userPlansByEmail === "object") {
Object.entries(raw.userPlansByEmail).forEach(([key, val]) => {
const email = toNonEmptyString(key).toLowerCase();
const planId = toNonEmptyString(val);
if (email && planId) userPlansByEmail[email] = planId;
});
}
return {
userPlansById,
userPlansByEmail,
};
}
export function getMembershipPlanById(config: MembershipConfig, planId: string): MembershipPlanConfig {
return (
config.plans.find((plan) => plan.id === planId) ||
config.plans.find((plan) => plan.id === config.defaultPlanId) ||
config.plans[0] ||
defaultMembershipConfig.plans[0]
);
}
export function resolveMembershipPlanIdForUser(
userId: string,
email: string | null | undefined,
config: MembershipConfig,
overrides: MembershipUserOverrides
): MembershipPlanId {
const normalizedEmail = toNonEmptyString(email).toLowerCase();
const byUserId = overrides.userPlansById[userId];
const byEmail = normalizedEmail ? overrides.userPlansByEmail[normalizedEmail] : "";
const availablePlanIds = new Set(config.plans.map((plan) => plan.id));
const resolved = byUserId || byEmail || config.defaultPlanId;
return availablePlanIds.has(resolved) ? resolved : config.defaultPlanId;
}