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

This commit is contained in:
2026-09-16 10:19:44 +08:00
parent e4f70f8449
commit d022454b2b
+116 -146
View File
@@ -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