chore(points): align types/api with final backend contract - apps/web/src/pages/points/Packages.tsx
This commit is contained in:
@@ -1,174 +1,144 @@
|
||||
/**
|
||||
* 积分充值页(/app/points/recharge)
|
||||
* 单独展示积分包,供入口直接跳转使用
|
||||
* 主 Plans 页面也有充值区,这里提供独立 URL 方便从"积分不足"弹窗跳转
|
||||
* 积分充值页面
|
||||
* 展示充值包列表 + 创建充值订单
|
||||
*
|
||||
* 注意:当前后端 POST /points/recharge 返回 pay_params={}(支付通道未接入),
|
||||
* 前端可完成订单创建 UI,但发起支付需等待支付通道接入。
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Card, Col, Row, Button, Tag, Typography, Space, Modal, message, Tooltip } from "antd"
|
||||
import { ThunderboltOutlined, SafetyCertificateOutlined, CrownFilled } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { Card, Button, Tag, message, Spin, Alert } from "antd"
|
||||
import { ThunderboltOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { createPointsOrder, getPointsPackages } from "@/api/points"
|
||||
import type { PointsPackage } from "@/api/points/types"
|
||||
import "./Points.css"
|
||||
import { getPointsPackages, createPointsOrder } from "@/api/points"
|
||||
import { getDiscountPriceCents } from "@/api/points/types"
|
||||
import type { PointsPackage, PointsPackagesResponse } from "@/api/points/types"
|
||||
import styles from "./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)
|
||||
const PointsPackagesPage: React.FC = () => {
|
||||
const { init, refreshBalance } = usePointsStore()
|
||||
const [pkgResp, setPkgResp] = useState<PointsPackagesResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ordering, setOrdering] = useState<string | null>(null)
|
||||
const [selectedCode, setSelectedCode] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
init()
|
||||
getPointsPackages()
|
||||
.then((r) => setPackages(r.packages))
|
||||
.catch(() => {})
|
||||
loadPackages()
|
||||
}, [init])
|
||||
|
||||
const getPackPrice = (pkg: PointsPackage): number => {
|
||||
const mt = balance?.member_type ?? "free"
|
||||
type DiscountKey =
|
||||
| "discounted_price_for_free"
|
||||
| "discounted_price_for_monthly"
|
||||
| "discounted_price_for_quarterly"
|
||||
| "discounted_price_for_yearly"
|
||||
const key = `discounted_price_for_${mt}` as DiscountKey
|
||||
return pkg[key] ?? pkg.price
|
||||
}
|
||||
|
||||
const handleBuy = async (pkg: PointsPackage) => {
|
||||
const loadPackages = async () => {
|
||||
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) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err?.message ?? "下单失败")
|
||||
setLoading(true)
|
||||
const data = await getPointsPackages()
|
||||
setPkgResp(data)
|
||||
if (data.packages.length > 0) setSelectedCode(data.packages[1]?.code || data.packages[0].code)
|
||||
} catch (err) {
|
||||
message.error("加载充值包失败")
|
||||
} finally {
|
||||
setBuying(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecharge = async (pkg: PointsPackage) => {
|
||||
try {
|
||||
setOrdering(pkg.code)
|
||||
const order = await createPointsOrder({ package_id: pkg.code })
|
||||
message.success(`订单已创建:${order.id}(支付通道待接入)`)
|
||||
// pay_params 目前是 {},支付通道接入后再处理跳转
|
||||
if (order.pay_params && Object.keys(order.pay_params).length > 0) {
|
||||
// TODO: 支付通道接入后发起支付
|
||||
console.log("[recharge] pay_params:", order.pay_params)
|
||||
}
|
||||
await refreshBalance()
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || "充值失败,请稍后再试")
|
||||
} finally {
|
||||
setOrdering(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className={styles.pointsPage}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
if (!pkgResp) return <div className={styles.pointsPage}>充值包加载失败</div>
|
||||
|
||||
const userDiscount = pkgResp.user_discount
|
||||
const hasDiscount = userDiscount != null && userDiscount < 1
|
||||
|
||||
return (
|
||||
<div className="xx-points-page">
|
||||
<PageHead
|
||||
title="积分充值"
|
||||
description="积分永久有效,可用于全部 AI 功能;付费会员享折扣"
|
||||
<div className={styles.pointsPage}>
|
||||
<h2 className={styles.pageTitle}>积分充值</h2>
|
||||
|
||||
actions={
|
||||
<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" }}>
|
||||
<ThunderboltOutlined /> {balance.balance.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
{hasDiscount && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={`当前账户享 ${Math.round(userDiscount * 100)}% 折扣优惠`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
<div className={styles.packageGrid}>
|
||||
{pkgResp.packages.map((pkg) => {
|
||||
const isSelected = selectedCode === pkg.code
|
||||
const discountPrice = getDiscountPriceCents(pkg, userDiscount)
|
||||
const isDiscounted = discountPrice < pkg.price_cents
|
||||
const priceYuan = (discountPrice / 100).toFixed(2).replace(/\.00$/, "")
|
||||
const originalYuan = (pkg.price_cents / 100).toFixed(2).replace(/\.00$/, "")
|
||||
|
||||
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
|
||||
<Card
|
||||
key={pkg.code}
|
||||
hoverable
|
||||
className={`${styles.packageCard} ${isSelected ? styles.selected : ""}`}
|
||||
onClick={() => setSelectedCode(pkg.code)}
|
||||
>
|
||||
{isSelected && <CheckCircleFilled className={styles.selectedIcon} />}
|
||||
<div className={styles.packagePoints}>
|
||||
<ThunderboltOutlined className={styles.pointsIcon} />
|
||||
<span className={styles.pointsNum}>{pkg.points}</span>
|
||||
<span className={styles.pointsUnit}>积分</span>
|
||||
</div>
|
||||
<div className={styles.packageName}>{pkg.name}</div>
|
||||
<div className={styles.packagePrice}>
|
||||
<span className={styles.priceSymbol}>¥</span>
|
||||
<span className={styles.priceNum}>{priceYuan}</span>
|
||||
{isDiscounted && <span className={styles.originalPrice}>¥{originalYuan}</span>}
|
||||
</div>
|
||||
{pkg.unit_price < 0.1 && (
|
||||
<Tag color="orange" className={styles.hotTag}>
|
||||
超值
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
loading={ordering === pkg.code}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRecharge(pkg)
|
||||
}}
|
||||
className={styles.buyBtn}
|
||||
>
|
||||
{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">
|
||||
<ThunderboltOutlined /> {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>
|
||||
立即充值
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="充值说明"
|
||||
description="积分永久有效;当前支付通道接入中,下单后请稍后刷新查看积分到账情况。"
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PointsRecharge
|
||||
export const Component = PointsRecharge
|
||||
export default PointsPackagesPage
|
||||
|
||||
Reference in New Issue
Block a user