chore(points): align types/api with final backend contract - apps/web/src/pages/points/Center.tsx

This commit is contained in:
2026-09-16 10:19:38 +08:00
parent 5745d51b5a
commit e4f70f8449
+126 -281
View File
@@ -1,309 +1,154 @@
/**
* 积分中心主页(/app/points)
* 展示余额、会员信息、本月统计、快捷入口
* 积分中心首页
* 展示:
* - 当前积分余额 + 会员状态(来自 balance + membership)
* - 每日免费额度(来自 dailyUsage)
* - 快捷入口(充值 / 消费记录 / 订阅)
*/
import React, { useEffect } from "react"
import {
Card,
Col,
Row,
Statistic,
Button,
Space,
Tag,
Typography,
Progress,
List,
Avatar,
Empty,
} from "antd"
import { Card, Button, Space, Statistic, Tag, Progress, Alert } from "antd"
import { useNavigate } from "react-router-dom"
import {
ThunderboltOutlined,
CrownFilled,
ArrowUpOutlined,
ArrowDownOutlined,
CrownOutlined,
HistoryOutlined,
WalletOutlined,
FileTextOutlined,
InfoCircleOutlined,
PlusCircleOutlined,
GiftOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import "./Points.css"
import styles from "./Points.css"
const { Text } = Typography
const SOURCE_NAME: Record<string, string> = {
recharge: "充值",
task_reward: "任务奖励",
ai_voice: "AI 配音",
ai_digital_human: "AI 数字人",
ai_video: "智能混剪",
voice_clone_train: "声音克隆训练",
voice_clone_synth: "声音克隆合成",
douyin_extract: "抖音提取",
ai_rewrite: "AI 改写",
ai_title: "AI 标题",
ai_cover: "AI 封面",
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
const MEMBER_LABEL: Record<string, string> = {
monthly: "月卡会员",
quarterly: "季卡会员",
yearly: "年卡会员",
}
const PointsCenter: React.FC = () => {
const PointsCenterPage: React.FC = () => {
const navigate = useNavigate()
const { balance, subscription, init, loading } = usePointsStore()
const { balance, dailyUsage, membership, subscription, rules, init } = usePointsStore()
useEffect(() => {
init()
}, [init])
const bal = balance?.balance ?? 0
const earned = balance?.total_earned ?? 0
const spent = balance?.total_spent ?? 0
const isMember = !!balance?.is_member
const freeUsed = balance?.free_clips_used ?? 0
const freeLimit = balance?.free_clips_limit ?? 2
const freeRemain = balance?.free_clips_remaining ?? (isMember ? 0 : 2)
// 近 5 条流水 mock(实际从 transactions 页加载)
const recentTx = [
{ type: "spend", source: "ai_voice", amount: 1, time: "今天 10:30" },
{ type: "spend", source: "ai_video", amount: 3, time: "今天 09:15" },
{ type: "earn", source: "task_reward", amount: 20, time: "昨天" },
]
const isMember = membership?.is_member ?? balance?.is_member ?? false
const memberKey =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : ""
const freeLimit = dailyUsage?.free_clips_limit ?? 0
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
const freePercent = freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0
const freeMultiplier = rules?.free_user_multiplier ?? 1.15
return (
<div className="xx-points-page">
<PageHead
title="积分中心"
description="管理积分余额、查看流水、充值使用"
<div className={styles.pointsPage}>
<h2 className={styles.pageTitle}>积分中心</h2>
actions={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/app/points/rules")}>
积分规则
</Button>
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
>
充值积分
</Button>
</Space>
}
/>
{/* 顶部大卡 */}
<Card className="xx-points-hero" bordered={false}>
<Row gutter={24} align="middle">
<Col xs={24} md={14}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text type="secondary" style={{ color: "rgba(255,255,255,0.8)" }}>
当前可用积分
</Text>
<div className="xx-points-hero-balance">
<ThunderboltOutlined />
<span>{loading ? "…" : bal.toLocaleString()}</span>
</div>
<Space size={8} wrap>
{isMember ? (
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
{subscription?.member_type === "yearly"
? "年卡"
: subscription?.member_type === "quarterly"
? "季卡"
: "月卡"}
会员
</Tag>
) : (
<Tag
style={{
padding: "4px 10px",
background: "rgba(255,255,255,0.2)",
color: "#fff",
border: "none",
}}
>
免费会员
</Tag>
)}
{balance?.member_expires_at && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{!isMember && (
<Button
size="small"
type="primary"
onClick={() => navigate("/app/subscription")}
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
>
<CrownFilled /> 升级会员享 8 折
</Button>
)}
</Space>
</Space>
</Col>
<Col xs={24} md={10}>
{/* 今日免费额度 */}
{!isMember && freeLimit > 0 && (
<div className="xx-points-free-quota">
<div className="xx-points-free-title">
<InfoCircleOutlined /> 今日免费混剪
</div>
<Progress
percent={Math.round((freeUsed / freeLimit) * 100)}
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
format={() => `${freeUsed}/${freeLimit} 条`}
/>
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
</Text>
</div>
)}
</Col>
</Row>
{/* 余额卡片 */}
<Card className={styles.balanceCard}>
<div className={styles.balanceMain}>
<Statistic
title="当前积分"
value={membership?.points_balance ?? balance?.balance ?? 0}
prefix={<ThunderboltOutlined style={{ color: "#faad14" }} />}
valueStyle={{ color: "#faad14", fontSize: 36, fontWeight: 700 }}
/>
{isMember && memberLabel && (
<Tag color="gold" icon={<CrownOutlined />} className={styles.memberTag}>
{memberLabel}
{membership?.max_resolution ? ` · ${membership.max_resolution}` : ""}
</Tag>
)}
</div>
<Space>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={() => navigate("/points/recharge")}
>
立即充值
</Button>
<Button icon={<HistoryOutlined />} onClick={() => navigate("/points/transactions")}>
消费记录
</Button>
<Button icon={<CrownOutlined />} onClick={() => navigate("/subscription")}>
{isMember ? "管理订阅" : "开通会员"}
</Button>
</Space>
{balance?.member_expires_at && isMember && (
<div className={styles.expireTip}>
会员到期时间:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</div>
)}
</Card>
{/* 统计 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计获得"
value={earned}
prefix={<ArrowUpOutlined style={{ color: "#10b981" }} />}
valueStyle={{ color: "#10b981" }}
{/* 每日免费额度卡片 */}
<Card title="🎁 每日免费额度" className={styles.freeCard}>
{!isMember && freeLimit > 0 ? (
<>
<Progress
percent={freePercent}
strokeColor={{ "0%": "#52c41a", "100%": "#faad14" }}
format={() => `${freeUsed}/${freeLimit}`}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计消耗"
value={spent}
prefix={<ArrowDownOutlined style={{ color: "#ef4444" }} />}
valueStyle={{ color: "#ef4444" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="可用积分"
value={bal}
prefix={<ThunderboltOutlined style={{ color: "#8b5cf6" }} />}
valueStyle={{ color: "#8b5cf6" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title={isMember ? "会员等级" : "今日免费剩余"}
value={isMember ? (subscription?.member_type_name ?? "付费会员") : `${freeRemain} 条`}
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
valueStyle={{ color: "#f59e0b" }}
/>
</Card>
</Col>
</Row>
<div className={styles.freeTip}>
<GiftOutlined /> 今日还剩 <b>{freeRemain}</b> 次免费生成额度
{dailyUsage?.reset_at && (
<span className={styles.resetTime}>
(
{new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}{" "}
重置)
</span>
)}
</div>
</>
) : isMember ? (
<Alert
type="info"
showIcon
message="会员享有更高免费额度"
description={`每日免费 ${subscription ? "(见会员权益)" : ""}生成次数,积分消耗享折扣`}
/>
) : (
<Alert
type="info"
showIcon
message="暂无可用免费额度"
description="充值或开通会员解锁更多权益"
/>
)}
</Card>
{/* 快捷入口 & 最近流水 */}
<Row gutter={[16, 16]} style={{ marginTop: 8 }}>
<Col xs={24} md={14}>
<Card
title={
<Space>
<HistoryOutlined />
最近流水
</Space>
}
extra={<a onClick={() => navigate("/app/points/transactions")}>查看全部 →</a>}
>
{recentTx.length === 0 ? (
<Empty description="暂无积分流水" />
) : (
<List
dataSource={recentTx}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
size="small"
style={{
background: item.type === "earn" ? "#d1fae5" : "#fee2e2",
color: item.type === "earn" ? "#059669" : "#dc2626",
}}
icon={item.type === "earn" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
/>
}
title={SOURCE_NAME[item.source] ?? item.source}
description={item.time}
/>
<div
style={{
color: item.type === "earn" ? "#10b981" : "#ef4444",
fontWeight: 700,
}}
>
{item.type === "earn" ? "+" : "-"}
{item.amount}
</div>
</List.Item>
)}
/>
)}
</Card>
</Col>
<Col xs={24} md={10}>
<Card title="快捷操作">
<Space direction="vertical" style={{ width: "100%" }} size={10}>
<Button
block
size="large"
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/app/subscription")}
>
充值积分
</Button>
<Button
block
size="large"
icon={<HistoryOutlined />}
onClick={() => navigate("/app/points/transactions")}
>
积分明细
</Button>
<Button
block
size="large"
icon={<CrownFilled />}
onClick={() => navigate("/app/subscription")}
>
{isMember ? "续费/升级会员" : "升级付费会员"}
</Button>
<Button
block
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate("/app/points/rules")}
>
积分消耗规则
</Button>
</Space>
</Card>
</Col>
</Row>
{/* 快捷提示 */}
{!isMember && rules && (
<Alert
type="warning"
showIcon
message={`非会员积分消耗倍率 ×${freeMultiplier},开通会员可享折扣`}
action={
<Button size="small" type="primary" onClick={() => navigate("/subscription")}>
开通会员
</Button>
}
style={{ marginTop: 16 }}
/>
)}
{/* 累计统计 */}
<Card title="📊 累计统计" className={styles.statsCard}>
<div className={styles.statsGrid}>
<Statistic title="累计获得" value={balance?.total_earned ?? 0} suffix="积分" />
<Statistic title="累计消耗" value={balance?.total_spent ?? 0} suffix="积分" />
</div>
</Card>
</div>
)
}
export default PointsCenter
export const Component = PointsCenter
export default PointsCenterPage