diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts new file mode 100644 index 000000000..89d675944 --- /dev/null +++ b/app/api/user/account-center/route.ts @@ -0,0 +1,113 @@ +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 { + getPointsLedgerByUserId, + getPointsSystemConfig, + getUserById, +} 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"; + +function mapLedgerReasonLabel(reason: string) { + if (reason === "image_generation") return "图片生成"; + if (reason === "gift") return "管理员赠送"; + if (reason === "recharge") return "积分充值"; + if (reason === "adjust") return "积分调整"; + return reason || "积分变动"; +} + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [dbUser, pointsConfig, ledger, configSetting, overridesSetting] = + await Promise.all([ + getUserById({ id: session.user.id }), + getPointsSystemConfig(), + getPointsLedgerByUserId({ userId: session.user.id, limit: 200 }), + 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 membershipConfig = configSetting?.value + ? normalizeMembershipConfig(configSetting.value) + : defaultMembershipConfig; + const membershipOverrides = overridesSetting?.value + ? normalizeMembershipUserOverrides(overridesSetting.value) + : defaultMembershipUserOverrides; + const currentPlanId = resolveMembershipPlanIdForUser( + session.user.id, + session.user.email, + membershipConfig, + membershipOverrides + ); + const currentPlan = getMembershipPlanById(membershipConfig, currentPlanId); + + const usageRecords = ledger.map((item) => ({ + id: item.id, + detail: item.note || mapLedgerReasonLabel(item.reason), + status: item.amount < 0 ? "consumed" : "earned", + amount: item.amount, + createdAt: item.createdAt?.toISOString() || new Date().toISOString(), + })); + + const billingRecords = ledger + .filter((item) => item.reason === "recharge" || item.reason === "gift") + .map((item) => ({ + id: item.id, + date: item.createdAt?.toISOString() || new Date().toISOString(), + category: item.reason === "recharge" ? "积分充值" : "积分赠送", + amount: item.amount, + status: "completed", + invoice: "-", + })); + + return NextResponse.json({ + profile: { + name: dbUser?.name || session.user.name || "", + email: dbUser?.email || session.user.email || "", + currentPlanName: currentPlan.name, + currentPlanId, + availablePoints: pointsConfig.enabled + ? Math.max(0, Number(dbUser?.pointsBalance || 0)) + : currentPlan.monthlyCredits, + monthlyCredits: currentPlan.monthlyCredits, + dailyFreeCredits: currentPlan.dailyFreeCredits, + }, + usageRecords, + billingRecords, + }); + } catch (error) { + console.error("[Account Center API] Error:", error); + return NextResponse.json( + { error: "Failed to fetch account center data" }, + { status: 500 } + ); + } +} + diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index a3860887d..bd5ed5269 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -133,6 +133,33 @@ type MembershipSummaryResponse = { }; }; +type AccountCenterResponse = { + profile: { + name: string; + email: string; + currentPlanName: string; + currentPlanId: string; + availablePoints: number; + monthlyCredits: number; + dailyFreeCredits: number; + }; + usageRecords: Array<{ + id: string; + detail: string; + status: "consumed" | "earned"; + amount: number; + createdAt: string; + }>; + billingRecords: Array<{ + id: string; + date: string; + category: string; + amount: number; + status: "completed" | "pending" | "failed"; + invoice: string; + }>; +}; + type LovartHomeProps = { user: HomeUser | null; isAuthenticated: boolean; @@ -219,6 +246,13 @@ function formatPrice(currency: string, value: number) { return `${currency}${value}`; } +function formatDateTime(value?: string) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleString("zh-CN", { hour12: false }); +} + function AccountMenuItem({ icon: Icon, label, @@ -260,6 +294,11 @@ export function LovartHome({ const [activeCategory, setActiveCategory] = useState("全部"); const [showAllProjects, setShowAllProjects] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [accountCenterOpen, setAccountCenterOpen] = useState(false); + const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">( + "account" + ); + const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all"); const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly"); const [selectedModelId, setSelectedModelId] = useState(""); const [imageParams, setImageParams] = useState({ @@ -306,6 +345,11 @@ export function LovartHome({ fetcher ); + const { data: accountCenterData } = useSWR( + isAuthenticated && accountCenterOpen ? "/api/user/account-center" : null, + fetcher + ); + const { data: modelsData } = useSWR<{ models: ModelOption[]; }>(isAuthenticated ? "/api/models" : null, fetcher); @@ -455,6 +499,12 @@ export function LovartHome({ return (canvasData?.data || []).length > 4; }, [canvasData?.data]); + const filteredUsageRecords = useMemo(() => { + const records = accountCenterData?.usageRecords || []; + if (usageFilter === "all") return records; + return records.filter((item) => item.status === usageFilter); + }, [accountCenterData?.usageRecords, usageFilter]); + const toolbox1Links = useMemo( () => (navLinksData?.links || []) @@ -820,6 +870,20 @@ export function LovartHome({ return (user.name || user.email || "用户").toString(); }, [user]); + const accountProfile = useMemo(() => { + return { + name: accountCenterData?.profile?.name || userDisplayName || "用户", + email: accountCenterData?.profile?.email || user?.email || "", + planName: accountCenterData?.profile?.currentPlanName || currentPlan?.name || "Free", + availablePoints: + accountCenterData?.profile?.availablePoints ?? membershipData?.summary?.currentCredits ?? 0, + monthlyCredits: + accountCenterData?.profile?.monthlyCredits ?? currentPlan?.monthlyCredits ?? 0, + dailyFreeCredits: + accountCenterData?.profile?.dailyFreeCredits ?? membershipData?.summary?.dailyFreeCredits ?? 0, + }; + }, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]); + return (
@@ -974,7 +1038,11 @@ export function LovartHome({ toast.info("账户管理后续开放")} + onClick={() => { + setAccountCenterTab("account"); + setUsageFilter("all"); + setAccountCenterOpen(true); + }} /> ) : null} + + + + 账户管理 + +
+
+ + + +
+ +
+ {accountCenterTab === "account" ? ( +
+
+
+
+
+ {userInitials} +
+
+
+ {accountProfile.name} +
+
{accountProfile.email}
+
+
+
+ + +
+
+
+ +
+
+ 账户信息 + 设备管理 +
+
+
当前订阅
+
{accountProfile.planName}
+
可用积分
+
{accountProfile.availablePoints.toLocaleString()}
+
每月更新积分
+
{accountProfile.monthlyCredits.toLocaleString()}
+
每日免费积分
+
{accountProfile.dailyFreeCredits.toLocaleString()}
+
+
+
+ ) : null} + + {accountCenterTab === "usage" ? ( +
+
+ 明细 + + 日期 + 积分消耗 +
+ {filteredUsageRecords.length > 0 ? ( +
+ {filteredUsageRecords.map((record, idx) => ( +
+
{record.detail}
+
{record.status === "consumed" ? "已消耗" : "已获取"}
+
{formatDateTime(record.createdAt)}
+
+ {record.amount > 0 ? `+${record.amount}` : record.amount} +
+
+ ))} +
+ ) : ( +
暂无数据
+ )} +
+ ) : null} + + {accountCenterTab === "billing" ? ( +
+
+ 日期 + 类别 + 金额 + 状态 + 发票 +
+ {accountCenterData?.billingRecords && accountCenterData.billingRecords.length > 0 ? ( +
+ {accountCenterData.billingRecords.map((record, idx) => ( +
+
{formatDateTime(record.date)}
+
{record.category}
+
{record.amount > 0 ? `+${record.amount}` : record.amount}
+
{record.status === "completed" ? "完成" : record.status === "pending" ? "处理中" : "失败"}
+
{record.invoice || "-"}
+
+ ))} +
+ ) : ( +
暂无数据
+ )} +
+ ) : null} +
+
+
+
+
diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 82a5c244e..03f802714 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -875,6 +875,37 @@ export async function consumeUserPoints({ } } +export async function getPointsLedgerByUserId({ + userId, + limit = 100, +}: { + userId: string; + limit?: number; +}) { + try { + return await db + .select({ + id: pointsLedger.id, + amount: pointsLedger.amount, + reason: pointsLedger.reason, + modelId: pointsLedger.modelId, + chatId: pointsLedger.chatId, + messageId: pointsLedger.messageId, + note: pointsLedger.note, + createdAt: pointsLedger.createdAt, + }) + .from(pointsLedger) + .where(eq(pointsLedger.userId, userId)) + .orderBy(desc(pointsLedger.createdAt)) + .limit(limit); + } catch (_error) { + throw new ChatSDKError( + "bad_request:database", + "Failed to get points ledger by user id" + ); + } +} + export async function getAllSystemSettings() { try { return await db.select().from(systemSettings);