新增新版首页模式切换与会员升级配置体系
This commit is contained in:
parent
3db32eceb0
commit
9b1de1a9f3
@ -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>
|
||||
|
||||
@ -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,23 +28,29 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
const [session, cookieStore, siteSettings, navLinks, tutorialVideos] = await Promise.all([
|
||||
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
|
||||
<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 => ({
|
||||
customLinks={navLinks.map((link) => ({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
@ -52,7 +61,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
||||
tutorialVideos={tutorialVideos.map(v => ({
|
||||
tutorialVideos={tutorialVideos.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
videoUrl: v.videoUrl,
|
||||
@ -62,11 +71,8 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
}))}
|
||||
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>
|
||||
>
|
||||
{children}
|
||||
</ChatShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,13 +25,42 @@ 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");
|
||||
|
||||
// 如果没有保存的模型 ID,从数据库获取默认模型
|
||||
|
||||
@ -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 [notificationSetting] = await db
|
||||
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);
|
||||
.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 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);
|
||||
|
||||
86
app/api/user/membership-summary/route.ts
Normal file
86
app/api/user/membership-summary/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
@ -750,6 +827,48 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
@ -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
92
components/chat-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
693
components/home/lovart-home.tsx
Normal file
693
components/home/lovart-home.tsx
Normal 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
16
lib/canvas-launch.ts
Normal 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
306
lib/membership/config.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user