chore(points): align types/api with final backend contract - apps/web/src/components/common/PointsBadge/index.tsx

This commit is contained in:
2026-09-16 10:19:26 +08:00
parent 875b54df7f
commit a4e54011ca
@@ -1,114 +1,58 @@
/**
* Header 右上角的积分徽章(💎 580)
* - 余额 <10 时橙色告警
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
* 头部积分徽章组件
* - 展示当前积分余额
* - 会员标签(基于 membership 聚合信息或 subscription.plan_id 判断)
* - 点击跳转积分中心
*/
import React, { useEffect } from "react"
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
import {
ThunderboltOutlined,
CrownOutlined,
RightOutlined,
WarningOutlined,
} from "@ant-design/icons"
import React from "react"
import { useNavigate } from "react-router-dom"
import { Tooltip, Typography } from "antd"
import { CrownOutlined, StarFilled } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import "./PointsBadge.css"
import styles from "./PointsBadge.module.css"
const { Text, Paragraph } = Typography
const { Text } = Typography
/** 会员类型标签映射(基于 GET /points/subscription/membership 返回的 member_type 字符串) */
const MEMBER_LABEL: Record<string, string> = {
monthly: "月卡会员",
quarterly: "季卡会员",
yearly: "年卡会员",
}
const MEMBER_COLOR: Record<string, string> = {
monthly: "#fa8c16",
quarterly: "#eb2f96",
yearly: "#fadb14",
}
const PointsBadge: React.FC = () => {
const navigate = useNavigate()
const { balance, init, loading } = usePointsStore()
const { balance, membership, subscription } = usePointsStore()
useEffect(() => {
if (!balance) init()
}, [balance, init])
const bal = balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
const zero = bal === 0
const isMember = !!balance?.is_member
const memberLabel = isMember
? balance?.member_type === "yearly"
? "年卡会员"
: balance?.member_type === "quarterly"
? "季卡会员"
: balance?.member_type === "monthly"
? "月卡会员"
: "付费会员"
: "免费会员"
const popContent = (
<div className="xx-points-popover">
<div className="xx-points-popover-top">
<div className="xx-points-popover-balance">
<ThunderboltOutlined className="xx-points-icon" />
<span className="xx-points-value">{loading ? "…" : bal}</span>
<span className="xx-points-unit">积分</span>
</div>
<Tag color={isMember ? "gold" : "default"} icon={<CrownOutlined />}>
{memberLabel}
</Tag>
</div>
{(zero || lowBalance) && (
<Paragraph type="warning" className="xx-points-warn">
<WarningOutlined /> 积分不足,充值后可继续使用 AI 功能
</Paragraph>
)}
{balance?.member_expires_at && (
<Text type="secondary" className="xx-points-expire">
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
<div className="xx-points-popover-stats">
<div>
<Text type="secondary">累计获得</Text>
<div className="xx-points-stat-val">+{balance?.total_earned ?? 0}</div>
</div>
<div>
<Text type="secondary">累计消耗</Text>
<div className="xx-points-stat-val">-{balance?.total_spent ?? 0}</div>
</div>
</div>
<Space direction="vertical" style={{ width: "100%" }} size={8}>
<Button
type="primary"
icon={<ThunderboltOutlined />}
block
onClick={() => navigate("/app/points")}
>
充值积分
</Button>
<Button block onClick={() => navigate("/app/points/transactions")}>
积分明细
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/app/subscription")}>
<CrownOutlined /> 升级会员享折扣
</Button>
)}
</Space>
</div>
)
const points = membership?.points_balance ?? balance?.balance ?? 0
// 优先用 membership.member_type 判定会员状态;否则用 subscription.plan_id
const memberKey: string | null =
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const isMember = membership?.is_member ?? balance?.is_member ?? false
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : ""
const memberColor = memberKey ? MEMBER_COLOR[memberKey] || "#fa8c16" : "#fa8c16"
return (
<Popover content={popContent} placement="bottomRight" trigger="click" arrow={false}>
<button
type="button"
className={`xx-points-badge ${zero ? "zero" : lowBalance ? "low" : ""}`}
>
<Badge dot={zero} color={zero ? "#faad14" : undefined}>
<ThunderboltOutlined className="xx-points-badge-icon" />
</Badge>
<span className="xx-points-badge-val">{loading ? "…" : bal}</span>
</button>
</Popover>
<div className={styles.pointsBadge} onClick={() => navigate("/points")}>
<StarFilled className={styles.pointsIcon} />
<Text strong className={styles.pointsText}>
{points}
</Text>
{isMember && memberLabel && (
<Tooltip title={`${memberLabel} · ${membership?.max_resolution ?? ""}`}>
<span className={styles.memberBadge} style={{ color: memberColor }}>
<CrownOutlined /> {memberLabel}
</span>
</Tooltip>
)}
</div>
)
}