feat(points): apps/web/src/pages/points/Packages.tsx
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user