新增新版首页模式切换与会员升级配置体系
This commit is contained in:
parent
3db32eceb0
commit
9b1de1a9f3
@ -67,6 +67,12 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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());
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
@ -170,6 +176,10 @@ type SiteSettings = {
|
|||||||
canvasEnabled: boolean;
|
canvasEnabled: boolean;
|
||||||
canvasDefaultModelId: string | null;
|
canvasDefaultModelId: string | null;
|
||||||
canvasAutoModeDefaultModelId: string | null;
|
canvasAutoModeDefaultModelId: string | null;
|
||||||
|
homeVariant: "classic" | "lovart";
|
||||||
|
surpriseCategories: string[];
|
||||||
|
membershipConfig: ReturnType<typeof normalizeMembershipConfig>;
|
||||||
|
membershipUserOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavLink = {
|
type NavLink = {
|
||||||
@ -388,10 +398,27 @@ export function AdminSettingsClient() {
|
|||||||
canvasEnabled: true,
|
canvasEnabled: true,
|
||||||
canvasDefaultModelId: null,
|
canvasDefaultModelId: null,
|
||||||
canvasAutoModeDefaultModelId: 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(() => {
|
useEffect(() => {
|
||||||
if (siteSettingsData) {
|
if (siteSettingsData) {
|
||||||
|
const normalizedMembershipConfig = normalizeMembershipConfig(
|
||||||
|
(siteSettingsData as any).membershipConfig
|
||||||
|
);
|
||||||
|
const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
|
||||||
|
(siteSettingsData as any).membershipUserOverrides
|
||||||
|
);
|
||||||
|
|
||||||
setSiteSettings({
|
setSiteSettings({
|
||||||
siteName: siteSettingsData.siteName || "AI Chatbot",
|
siteName: siteSettingsData.siteName || "AI Chatbot",
|
||||||
siteLogo: siteSettingsData.siteLogo || "",
|
siteLogo: siteSettingsData.siteLogo || "",
|
||||||
@ -416,7 +443,15 @@ export function AdminSettingsClient() {
|
|||||||
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
|
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
|
||||||
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
||||||
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || 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]);
|
}, [siteSettingsData]);
|
||||||
|
|
||||||
@ -895,12 +930,42 @@ export function AdminSettingsClient() {
|
|||||||
// --- 网站设置逻辑 ---
|
// --- 网站设置逻辑 ---
|
||||||
const handleSaveSiteSettings = async () => {
|
const handleSaveSiteSettings = async () => {
|
||||||
try {
|
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", {
|
const response = await fetch("/api/site-settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(siteSettings),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("保存失败");
|
if (!response.ok) throw new Error("保存失败");
|
||||||
|
setSiteSettings(payload);
|
||||||
|
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
|
||||||
|
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
|
||||||
toast.success("网站设置保存成功");
|
toast.success("网站设置保存成功");
|
||||||
mutateSiteSettings();
|
mutateSiteSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2500,6 +2565,95 @@ export function AdminSettingsClient() {
|
|||||||
设置新用户首次访问时的默认主题模式
|
设置新用户首次访问时的默认主题模式
|
||||||
</div>
|
</div>
|
||||||
</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="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { ChatShell } from "@/components/chat-shell";
|
||||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
||||||
import { auth } from "../(auth)/auth";
|
import { auth } from "../(auth)/auth";
|
||||||
import { TopNavbar } from "@/components/top-navbar";
|
import {
|
||||||
import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries";
|
getNavLinks,
|
||||||
|
getSiteSettings,
|
||||||
|
getSystemSetting,
|
||||||
|
getTutorialVideos,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -25,48 +28,51 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function SidebarWrapper({ 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] =
|
||||||
auth(),
|
await Promise.all([
|
||||||
cookies(),
|
auth(),
|
||||||
getSiteSettings(),
|
cookies(),
|
||||||
getNavLinks(),
|
getSiteSettings(),
|
||||||
getTutorialVideos(),
|
getNavLinks(),
|
||||||
]);
|
getTutorialVideos(),
|
||||||
|
getSystemSetting({ key: "home_variant" }).catch(() => undefined),
|
||||||
|
]);
|
||||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||||
|
const homeVariant =
|
||||||
|
homeVariantSetting?.value === "lovart" ? "lovart" : "classic";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
<ChatShell
|
||||||
<TopNavbar
|
user={session?.user}
|
||||||
siteName={siteSettings.siteName}
|
isCollapsed={isCollapsed}
|
||||||
siteLogo={siteSettings.siteLogo || undefined}
|
homeVariant={homeVariant}
|
||||||
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
siteName={siteSettings.siteName}
|
||||||
isAdmin={session?.user?.type === "admin"}
|
siteLogo={siteSettings.siteLogo || undefined}
|
||||||
customLinks={navLinks.map(link => ({
|
siteLogoDark={siteSettings.siteLogoDark || undefined}
|
||||||
id: link.id,
|
isAdmin={session?.user?.type === "admin"}
|
||||||
name: link.name,
|
customLinks={navLinks.map((link) => ({
|
||||||
url: link.url,
|
id: link.id,
|
||||||
order: link.order,
|
name: link.name,
|
||||||
toolboxGroup: link.toolboxGroup || 1,
|
url: link.url,
|
||||||
}))}
|
order: link.order,
|
||||||
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
|
toolboxGroup: link.toolboxGroup || 1,
|
||||||
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
}))}
|
||||||
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
|
||||||
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
|
||||||
tutorialVideos={tutorialVideos.map(v => ({
|
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
|
||||||
id: v.id,
|
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
|
||||||
title: v.title,
|
tutorialVideos={tutorialVideos.map((v) => ({
|
||||||
videoUrl: v.videoUrl,
|
id: v.id,
|
||||||
coverUrl: v.coverUrl,
|
title: v.title,
|
||||||
description: v.description,
|
videoUrl: v.videoUrl,
|
||||||
order: v.order,
|
coverUrl: v.coverUrl,
|
||||||
}))}
|
description: v.description,
|
||||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
order: v.order,
|
||||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
}))}
|
||||||
/>
|
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||||
<AppSidebar user={session?.user} />
|
>
|
||||||
<SidebarInset className="h-full">{children}</SidebarInset>
|
{children}
|
||||||
</div>
|
</ChatShell>
|
||||||
</SidebarProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,15 @@ import { redirect } from "next/navigation";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import { DataStreamHandler } from "@/components/data-stream-handler";
|
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||||
|
import { LovartHome } from "@/components/home/lovart-home";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
import { auth } from "../(auth)/auth";
|
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";
|
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@ -19,12 +25,41 @@ export default function Page() {
|
|||||||
async function NewChatPage() {
|
async function NewChatPage() {
|
||||||
const id = generateUUID();
|
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) {
|
if (!session?.user) {
|
||||||
redirect("/login");
|
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");
|
const modelIdFromCookie = cookieStore.get("chat-model");
|
||||||
|
|
||||||
|
|||||||
@ -4,20 +4,95 @@ import { db } from "@/lib/db";
|
|||||||
import { siteSettings, systemSettings } from "@/lib/db/schema";
|
import { siteSettings, systemSettings } from "@/lib/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
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 - 获取网站设置 (公开)
|
// GET /api/site-settings - 获取网站设置 (公开)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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 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 || {
|
const baseSettings = settings || {
|
||||||
@ -44,11 +119,19 @@ export async function GET() {
|
|||||||
canvasEnabled: true,
|
canvasEnabled: true,
|
||||||
canvasDefaultModelId: null,
|
canvasDefaultModelId: null,
|
||||||
canvasAutoModeDefaultModelId: null,
|
canvasAutoModeDefaultModelId: null,
|
||||||
|
homeVariant,
|
||||||
|
surpriseCategories,
|
||||||
|
membershipConfig,
|
||||||
|
membershipUserOverrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
notification,
|
notification,
|
||||||
|
homeVariant,
|
||||||
|
surpriseCategories,
|
||||||
|
membershipConfig,
|
||||||
|
membershipUserOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 禁用缓存,确保总是获取最新设置
|
// 禁用缓存,确保总是获取最新设置
|
||||||
@ -90,6 +173,10 @@ const updateSchema = z.object({
|
|||||||
canvasEnabled: z.boolean().optional(),
|
canvasEnabled: z.boolean().optional(),
|
||||||
canvasDefaultModelId: z.string().nullable().optional(),
|
canvasDefaultModelId: z.string().nullable().optional(),
|
||||||
canvasAutoModeDefaultModelId: 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 - 更新网站设置(仅管理员)
|
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
||||||
@ -107,6 +194,13 @@ export async function PUT(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = updateSchema.parse(body);
|
const data = updateSchema.parse(body);
|
||||||
|
const {
|
||||||
|
homeVariant,
|
||||||
|
surpriseCategories,
|
||||||
|
membershipConfig,
|
||||||
|
membershipUserOverrides,
|
||||||
|
...siteSettingsPayload
|
||||||
|
} = data;
|
||||||
|
|
||||||
// 查找现有设置
|
// 查找现有设置
|
||||||
const [existing] = await db.select().from(siteSettings).limit(1);
|
const [existing] = await db.select().from(siteSettings).limit(1);
|
||||||
@ -116,47 +210,235 @@ export async function PUT(request: NextRequest) {
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(siteSettings)
|
.update(siteSettings)
|
||||||
.set({
|
.set({
|
||||||
...data,
|
...siteSettingsPayload,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
.where(eq(siteSettings.id, existing.id))
|
.where(eq(siteSettings.id, existing.id))
|
||||||
.returning();
|
.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 {
|
} else {
|
||||||
// 创建新设置
|
// 创建新设置
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(siteSettings)
|
.insert(siteSettings)
|
||||||
.values({
|
.values({
|
||||||
siteName: data.siteName || "AI Chatbot",
|
siteName: siteSettingsPayload.siteName || "AI Chatbot",
|
||||||
siteLogo: data.siteLogo,
|
siteLogo: siteSettingsPayload.siteLogo,
|
||||||
siteLogoDark: data.siteLogoDark,
|
siteLogoDark: siteSettingsPayload.siteLogoDark,
|
||||||
favicon: data.favicon,
|
favicon: siteSettingsPayload.favicon,
|
||||||
showAutoModel: data.showAutoModel ?? true,
|
showAutoModel: siteSettingsPayload.showAutoModel ?? true,
|
||||||
defaultTheme: data.defaultTheme ?? "system",
|
defaultTheme: siteSettingsPayload.defaultTheme ?? "system",
|
||||||
usageTimeEnabled: data.usageTimeEnabled ?? false,
|
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
|
||||||
usageTimeStart: data.usageTimeStart ?? "08:00",
|
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
|
||||||
usageTimeEnd: data.usageTimeEnd ?? "20:00",
|
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
|
||||||
usageTimeMessage: data.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
||||||
bannerEnabled: data.bannerEnabled ?? true,
|
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||||
bannerText: data.bannerText ?? "升级会员, Nano Banana Pro 免费365天!",
|
bannerText: siteSettingsPayload.bannerText ?? "升级会员, Nano Banana Pro 免费365天!",
|
||||||
bannerLink: data.bannerLink,
|
bannerLink: siteSettingsPayload.bannerLink,
|
||||||
toolbox1Name: data.toolbox1Name ?? "工具箱",
|
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
|
||||||
toolbox1Enabled: data.toolbox1Enabled ?? true,
|
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
|
||||||
toolbox2Name: data.toolbox2Name ?? "工具箱2",
|
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
|
||||||
toolbox2Enabled: data.toolbox2Enabled ?? false,
|
toolbox2Enabled: siteSettingsPayload.toolbox2Enabled ?? false,
|
||||||
tutorialVideoUrl: data.tutorialVideoUrl,
|
tutorialVideoUrl: siteSettingsPayload.tutorialVideoUrl,
|
||||||
tutorialEnabled: data.tutorialEnabled ?? false,
|
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
|
||||||
rankingEnabled: data.rankingEnabled ?? true,
|
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
|
||||||
canvasEnabled: data.canvasEnabled ?? true,
|
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
|
||||||
canvasDefaultModelId: data.canvasDefaultModelId,
|
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
|
||||||
canvasAutoModeDefaultModelId: data.canvasAutoModeDefaultModelId,
|
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.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) {
|
} catch (error) {
|
||||||
console.error("[Site Settings API] Update error:", 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 { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
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 { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||||
|
|
||||||
@ -210,6 +211,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const hiddenPromptRef = useRef<string | null>(null);
|
const hiddenPromptRef = useRef<string | null>(null);
|
||||||
const autoContextRef = useRef<string | null>(null);
|
const autoContextRef = useRef<string | null>(null);
|
||||||
|
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
|
||||||
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 🆕 标签悬停预览状态
|
// 🆕 标签悬停预览状态
|
||||||
@ -447,6 +449,81 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition);
|
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,当用户点击"添加到对话"按钮时添加标签
|
// 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingChatAttachments.length > 0) {
|
if (pendingChatAttachments.length > 0) {
|
||||||
@ -749,6 +826,48 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
toast.error(error.message || "生成失败");
|
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);
|
const hasLoadedHistory = useRef(false);
|
||||||
@ -771,6 +890,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
|||||||
isInitialLoad.current = true;
|
isInitialLoad.current = true;
|
||||||
hiddenPromptRef.current = null;
|
hiddenPromptRef.current = null;
|
||||||
autoContextRef.current = null;
|
autoContextRef.current = null;
|
||||||
|
pendingLaunchRef.current = null;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput("");
|
setInput("");
|
||||||
setAttachments([]);
|
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