feat(points): apps/web/src/pages/points/Packages.tsx

This commit is contained in:
2026-09-15 08:39:07 +08:00
parent cec11a1680
commit c09b9d66a6
+167
View File
@@ -0,0 +1,167 @@
/**
* 积分充值页(/app/points/recharge)
* 单独展示积分包,供入口直接跳转使用
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
*/
import React, { useEffect, useState } from "react"
import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd"
import { DiamondOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import { createPointsOrder, getPointsPackages } from "@/api/points"
import type { PointsPackage } from "@/api/points/types"
import "./Points.css"
const { Title, Text, Paragraph } = Typography
const PointsRecharge: React.FC = () => {
const navigate = useNavigate()
const { balance, init } = usePointsStore()
const [packages, setPackages] = useState<PointsPackage[]>([])
const [buying, setBuying] = useState<string | null>(null)
useEffect(() => {
init()
getPointsPackages()
.then((r) => setPackages(r.packages))
.catch(() => {})
}, [init])
const getPackPrice = (pkg: PointsPackage): number => {
const mt = balance?.member_type ?? "free"
const key = `discounted_price_for_${mt}` as const
return (pkg as any)[key] ?? pkg.price
}
const handleBuy = async (pkg: PointsPackage) => {
try {
setBuying(pkg.id)
const order = await createPointsOrder({ package_id: pkg.id })
Modal.info({
title: "支付功能开发中",
icon: <SafetyCertificateOutlined />,
content: (
<div>
<Paragraph>
订单已创建({order.id.slice(0, 16)}…),金额{" "}
<b>¥{(order.price_cents / 100).toFixed(2).replace(/\.00$/, "")}</b>。
微信/支付宝支付正在接入中。
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
现阶段所有功能免费体验,积分仅为演示数据。
</Paragraph>
</div>
),
okText: "知道了",
})
} catch (e: any) {
message.error(e?.message ?? "下单失败")
} finally {
setBuying(null)
}
}
return (
<div className="xx-points-page">
<PageHead
title="积分充值"
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
extra={
<Space>
{!balance?.is_member && (
<Button icon={<CrownFilled />} onClick={() => navigate("/app/subscription")}>
升级会员 8 折起
</Button>
)}
<Button onClick={() => navigate("/app/points/transactions")}>积分明细</Button>
</Space>
}
/>
{balance && (
<Card
bordered={false}
style={{ marginBottom: 16, background: "linear-gradient(135deg,#ede9fe,#fce7f3)" }}
>
<Space size="large">
<div>
<Text type="secondary">当前可用积分</Text>
<div style={{ fontSize: 28, fontWeight: 800, color: "#7c3aed" }}>
<DiamondOutlined /> {balance.balance.toLocaleString()}
</div>
</div>
</Space>
</Card>
)}
<Title level={4}>选择积分包</Title>
<Row gutter={[20, 20]}>
{packages.map((pkg) => {
const price = getPackPrice(pkg)
const discount = price < pkg.price ? Math.round((1 - price / pkg.price) * 100) : 0
const unit = price / pkg.points
return (
<Col xs={24} sm={12} md={8} key={pkg.id}>
<Card
className={`xx-pkg-card ${pkg.id === "basic_pack" ? "recommended" : ""} ${discount > 0 ? "has-discount" : ""}`}
hoverable
>
{pkg.id === "basic_pack" && <div className="xx-pkg-badge">热门</div>}
{discount > 0 && (
<Tag color="gold" className="xx-pkg-discount">
会员{10 - discount / 10}折
</Tag>
)}
<div className="xx-pkg-name">{pkg.name}</div>
<div className="xx-pkg-points">
<DiamondOutlined /> {pkg.points.toLocaleString()} 积分
</div>
<div className="xx-pkg-price">
<span className="currency">¥</span>
<span className="amount">
{(price / 100).toFixed(price % 100 === 0 ? 0 : 1).replace(/\.0$/, "")}
</span>
{discount > 0 && (
<span className="xx-pkg-origin">¥{(pkg.price / 100).toFixed(0)}</span>
)}
</div>
<div className="xx-pkg-unit">≈¥{unit.toFixed(3)}/积分 · 永久有效</div>
<Button
block
type={pkg.id === "basic_pack" ? "primary" : "default"}
loading={buying === pkg.id}
onClick={() => handleBuy(pkg)}
size="large"
style={{ marginTop: 16 }}
>
立即购买
</Button>
</Card>
</Col>
)
})}
</Row>
<Card style={{ marginTop: 24 }}>
<Title level={5}>积分消耗说明</Title>
<ul style={{ paddingLeft: 20, color: "var(--text-secondary)", lineHeight: 2 }}>
<li>智能混剪:3 积分/条(≤30s),每加 30s +1 积分</li>
<li>AI 配音 / 声音克隆合成:1 积分/分钟</li>
<li>AI 数字人:15 积分/分钟</li>
<li>抖音链接提取 / AI 改写 / AI 标题 / AI 封面:1~2 积分/次</li>
<li>声音克隆训练:免费</li>
<li>免费用户每日 2 条混剪免费,其余 AI 功能消耗为会员价 ×1.15</li>
</ul>
<Tooltip title="具体规则以系统实际计算为准">
<Text type="secondary" style={{ fontSize: 12 }}>
<SafetyCertificateOutlined /> 所有规则以页面实际显示为准
</Text>
</Tooltip>
</Card>
</div>
)
}
export default PointsRecharge
export const Component = PointsRecharge