新增新首页账户中心三页签并接入积分流水
This commit is contained in:
parent
86c6a8d169
commit
b5420a1156
113
app/api/user/account-center/route.ts
Normal file
113
app/api/user/account-center/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user