新增新首页账户中心三页签并接入积分流水
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 14:51:19 +08:00
parent 86c6a8d169
commit b5420a1156
3 changed files with 372 additions and 1 deletions

View File

@ -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 }
);
}
}

View File

@ -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<string>("");
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
@ -306,6 +345,11 @@ export function LovartHome({
fetcher
);
const { data: accountCenterData } = useSWR<AccountCenterResponse>(
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 (
<div className="min-h-svh bg-[#f4f4f4] text-[#171717]">
<header className="fixed left-0 top-0 z-40 h-[68px] w-full bg-[#f4f4f4]/90 backdrop-blur">
@ -974,7 +1038,11 @@ export function LovartHome({
<AccountMenuItem
icon={User}
label="账户管理"
onClick={() => toast.info("账户管理后续开放")}
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
/>
<AccountMenuItem
icon={Info}
@ -1511,6 +1579,165 @@ export function LovartHome({
</div>
) : null}
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
<DialogContent className="max-h-[92vh] w-[96vw] max-w-[1220px] overflow-hidden rounded-[30px] border-black/10 bg-[#f6f6f7] p-0">
<DialogHeader className="sr-only">
<DialogTitle>账户管理</DialogTitle>
</DialogHeader>
<div className="flex h-full max-h-[92vh] flex-col p-7">
<div className="mb-4 flex items-center gap-8 border-b border-black/8 pb-3">
<button
type="button"
className={`text-[40px] font-semibold leading-none ${accountCenterTab === "account" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("account")}
>
账户管理
</button>
<button
type="button"
className={`text-[40px] font-semibold leading-none ${accountCenterTab === "usage" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("usage")}
>
用量
</button>
<button
type="button"
className={`text-[40px] font-semibold leading-none ${accountCenterTab === "billing" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("billing")}
>
账单
</button>
</div>
<div className="flex-1 overflow-y-auto pr-1">
{accountCenterTab === "account" ? (
<div className="space-y-4">
<div className="rounded-[22px] border border-black/10 bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#d4551f] text-[20px] font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0">
<div className="truncate text-[20px] font-semibold text-black">
{accountProfile.name}
</div>
<div className="truncate text-[14px] text-black/45">{accountProfile.email}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
className="h-10 rounded-2xl bg-[#2f3542] px-4 text-sm text-white hover:bg-[#242a36] hover:text-white"
onClick={() => toast.info("编辑资料功能后续开放")}
>
编辑资料
</Button>
<Button
variant="ghost"
className="h-10 rounded-2xl border border-black/10 bg-white px-4 text-sm text-black/75 hover:bg-black/5"
onClick={() => {
void signOut({ redirect: true, callbackUrl: "/" });
}}
>
退出登录
</Button>
</div>
</div>
</div>
<div className="rounded-[22px] border border-black/10 bg-white px-5 py-5">
<div className="mb-3 flex items-center gap-6 text-[16px] font-semibold">
<span className="text-black">账户信息</span>
<span className="text-black/30">设备管理</span>
</div>
<div className="grid grid-cols-[150px_1fr] gap-y-3 text-[16px]">
<div className="text-black/55">当前订阅</div>
<div className="text-black">{accountProfile.planName}</div>
<div className="text-black/55">可用积分</div>
<div className="text-black">{accountProfile.availablePoints.toLocaleString()}</div>
<div className="text-black/55">每月更新积分</div>
<div className="text-black">{accountProfile.monthlyCredits.toLocaleString()}</div>
<div className="text-black/55">每日免费积分</div>
<div className="text-black">{accountProfile.dailyFreeCredits.toLocaleString()}</div>
</div>
</div>
</div>
) : null}
{accountCenterTab === "usage" ? (
<div className="rounded-[22px] border border-black/10 bg-white">
<div className="grid grid-cols-[2fr_1fr_2fr_1fr] items-center border-b border-black/8 px-5 py-3 text-[14px] font-semibold text-black/80">
<span>明细</span>
<Select value={usageFilter} onValueChange={(value: "all" | "consumed" | "earned") => setUsageFilter(value)}>
<SelectTrigger className="h-8 w-[120px] border-black/10 bg-white text-[13px]">
<span>{usageFilter === "all" ? "全部" : usageFilter === "consumed" ? "已消耗" : "已获取"}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="consumed">已消耗</SelectItem>
<SelectItem value="earned">已获取</SelectItem>
</SelectContent>
</Select>
<span>日期</span>
<span>积分消耗</span>
</div>
{filteredUsageRecords.length > 0 ? (
<div>
{filteredUsageRecords.map((record, idx) => (
<div
key={record.id}
className={`grid grid-cols-[2fr_1fr_2fr_1fr] items-center px-5 py-3 text-[14px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
>
<div className="truncate pr-3 text-black/80">{record.detail}</div>
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
<div className="text-black/65">{formatDateTime(record.createdAt)}</div>
<div className={record.amount < 0 ? "text-black" : "text-[#0f8a3c]"}>
{record.amount > 0 ? `+${record.amount}` : record.amount}
</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
)}
</div>
) : null}
{accountCenterTab === "billing" ? (
<div className="rounded-[22px] border border-black/10 bg-white">
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center border-b border-black/8 px-5 py-3 text-[14px] font-semibold text-black/80">
<span>日期</span>
<span>类别</span>
<span>金额</span>
<span>状态</span>
<span>发票</span>
</div>
{accountCenterData?.billingRecords && accountCenterData.billingRecords.length > 0 ? (
<div>
{accountCenterData.billingRecords.map((record, idx) => (
<div
key={record.id}
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center px-5 py-3 text-[14px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
>
<div className="text-black/65">{formatDateTime(record.date)}</div>
<div className="text-black/80">{record.category}</div>
<div className="text-black/80">{record.amount > 0 ? `+${record.amount}` : record.amount}</div>
<div className="text-black/55">{record.status === "completed" ? "完成" : record.status === "pending" ? "处理中" : "失败"}</div>
<div className="text-black/35">{record.invoice || "-"}</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
)}
</div>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-[1240px] overflow-hidden rounded-3xl border-black/10 bg-[#f7f7f7] p-0">
<div className="flex h-full max-h-[92vh] flex-col">

View File

@ -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);