chore(points): fix lint - apps/web/src/components/common/PointsBadge/index.tsx
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

This commit is contained in:
2026-09-16 10:43:33 +08:00
parent 75e06bed45
commit bfcea1e443
@@ -1,58 +1,130 @@
/**
* 头部积分徽章组件
* - 展示当前积分余额
* - 会员标签(基于 membership 聚合信息或 subscription.plan_id 判断)
* - 点击跳转积分中心
* Header 右上角积分徽章
* - 余额 <10 时橙色告警
* - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口
*
* 字段对齐新契约:
* - balance.is_member / balance.member_type 保留但降级;推荐用 membership.member_type
* - 免费额度、会员 max_resolution 在 popover 展示
*/
import React from "react"
import React, { useEffect } from "react"
import { Popover, Button, Tag, Space, Typography, Badge } from "antd"
import {
ThunderboltOutlined,
CrownOutlined,
RightOutlined,
WarningOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { Tooltip, Typography } from "antd"
import { CrownOutlined, StarFilled } from "@ant-design/icons"
import { usePointsStore } from "@/store/pointsStore"
import styles from "./PointsBadge.module.css"
import "./PointsBadge.css"
const { Text } = Typography
const { Text, Paragraph } = 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, membership, subscription } = usePointsStore()
const { balance, membership, subscription, dailyUsage, init, loading } = usePointsStore()
const points = membership?.points_balance ?? balance?.balance ?? 0
// 优先用 membership.member_type 判定会员状态;否则用 subscription.plan_id
const memberKey: string | null =
useEffect(() => {
if (!balance) init()
}, [balance, init])
// 余额:优先用 membership.points_balance(冗余字段),降级 balance.balance
const bal = membership?.points_balance ?? balance?.balance ?? 0
const lowBalance = bal > 0 && bal < 10
const zero = bal === 0
const isMember = membership?.is_member ?? balance?.is_member ?? false
const memberKey =
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"
const memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "付费会员" : "免费会员"
const maxRes = membership?.max_resolution
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
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>
)}
{!isMember && dailyUsage && freeRemain > 0 && (
<Text type="secondary" className="xx-points-expire">
今日剩余免费次数:{freeRemain}/{dailyUsage.free_clips_limit}
</Text>
)}
{balance?.member_expires_at && isMember && (
<Text type="secondary" className="xx-points-expire">
会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
{maxRes ? ` · ${maxRes}` : ""}
</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("/points/recharge")}
>
充值积分
</Button>
<Button block onClick={() => navigate("/points/transactions")}>
积分明细
<RightOutlined />
</Button>
{!isMember && (
<Button block type="link" onClick={() => navigate("/subscription")}>
<CrownOutlined /> 升级会员享折扣
</Button>
)}
</Space>
</div>
)
return (
<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>
<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>
)
}