chore(points): fix lint - apps/web/src/pages/points/Center.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 / 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 / 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
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 4h38m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 4h38m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 4h38m3s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 4h38m5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 4h38m4s

This commit is contained in:
2026-09-16 10:44:36 +08:00
parent 3bebad374a
commit d03dd7ca42
+322 -125
View File
@@ -1,154 +1,351 @@
/**
* 积分中心首页
* 展示:
* - 当前积分余额 + 会员状态(来自 balance + membership)
* - 每日免费额度(来自 dailyUsage)
* - 快捷入口(充值 / 消费记录 / 订阅)
* 积分中心主页(/points 或 /app/points)
* 展示余额、会员信息、免费额度、快捷入口、最近流水
*
* 字段对齐新契约(2026-09-16):
* - balance 不含 free_clips_*,从 dailyUsage 取
* - subscription.member_type → plan_id(free/monthly/quarterly/yearly)
* - subscription.member_type_name → 前端 PLAN_LABEL 映射
*/
import React, { useEffect } from "react"
import { Card, Button, Space, Statistic, Tag, Progress, Alert } from "antd"
import { useNavigate } from "react-router-dom"
import {
Card,
Col,
Row,
Statistic,
Button,
Space,
Tag,
Typography,
Progress,
List,
Avatar,
Empty,
} from "antd"
import {
ThunderboltOutlined,
CrownOutlined,
CrownFilled,
ArrowUpOutlined,
ArrowDownOutlined,
HistoryOutlined,
PlusCircleOutlined,
GiftOutlined,
WalletOutlined,
FileTextOutlined,
InfoCircleOutlined,
} from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { usePointsStore } from "@/store/pointsStore"
import styles from "./Points.css"
import { PLAN_LABEL } from "@/api/subscription/types"
import "./Points.css"
const MEMBER_LABEL: Record<string, string> = {
monthly: "月卡会员",
quarterly: "季卡会员",
yearly: "年卡会员",
const { Text } = Typography
const SOURCE_NAME: Record<string, string> = {
recharge: "充值",
task_reward: "任务奖励",
ai_voice: "AI 配音",
ai_digital_human: "AI 数字人",
ai_video: "智能混剪",
voice_clone_train: "声音克隆训练",
voice_clone_synth: "声音克隆合成",
douyin_extract: "抖音提取",
ai_rewrite: "AI 改写",
ai_title: "AI 标题",
ai_cover: "AI 封面",
sign_up: "注册赠送",
bind_phone: "绑定手机",
gift: "活动赠送",
admin: "管理员调整",
}
const PointsCenterPage: React.FC = () => {
const sourceLabel = (src: string): string => {
if (src.startsWith("refund:")) return `${SOURCE_NAME[src.slice(7)] || src.slice(7)}退款`
return SOURCE_NAME[src] || src
}
/** 会员标签:优先取 membership.member_type,降级 subscription.plan_id */
const memberKey = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string } | null,
): string | null =>
membership?.member_type ??
(subscription?.plan_id && subscription.plan_id !== "free" ? subscription.plan_id : null)
const memberLabel = (
membership: { member_type: string | null } | null,
subscription: { plan_id: string; plan_name?: string } | null,
): string => {
const key = memberKey(membership, subscription)
if (!key) return "免费会员"
return PLAN_LABEL[key as keyof typeof PLAN_LABEL] || subscription?.plan_name || "付费会员"
}
const PointsCenter: React.FC = () => {
const navigate = useNavigate()
const { balance, dailyUsage, membership, subscription, rules, init } = usePointsStore()
const { balance, dailyUsage, membership, subscription, rules, init, loading } = usePointsStore()
useEffect(() => {
init()
}, [init])
const bal = membership?.points_balance ?? balance?.balance ?? 0
const earned = balance?.total_earned ?? 0
const spent = balance?.total_spent ?? 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 memberLabel = memberKey ? MEMBER_LABEL[memberKey] || "" : ""
const freeLimit = dailyUsage?.free_clips_limit ?? 0
// 免费额度从 dailyUsage 取
const freeUsed = dailyUsage?.free_clips_used ?? 0
const freeLimit = dailyUsage?.free_clips_limit ?? (isMember ? 0 : 3)
const freeRemain = dailyUsage?.free_clips_remaining ?? 0
const freePercent = freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0
const freeMultiplier = rules?.free_user_multiplier ?? 1.15
// 最近流水 mock(后续可改为调用 getPointsTransactions(1,5))
const recentTx = [
{ type: "deduct" as const, source: "ai_voice", amount: 1, time: "今天 10:30" },
{ type: "deduct" as const, source: "ai_video", amount: 3, time: "今天 09:15" },
{ type: "add" as const, source: "recharge", amount: 100, time: "昨天" },
]
return (
<div className={styles.pointsPage}>
<h2 className={styles.pageTitle}>积分中心</h2>
{/* 余额卡片 */}
<Card className={styles.balanceCard}>
<div className={styles.balanceMain}>
<Statistic
title="当前积分"
value={membership?.points_balance ?? balance?.balance ?? 0}
prefix={<ThunderboltOutlined style={{ color: "#faad14" }} />}
valueStyle={{ color: "#faad14", fontSize: 36, fontWeight: 700 }}
/>
{isMember && memberLabel && (
<Tag color="gold" icon={<CrownOutlined />} className={styles.memberTag}>
{memberLabel}
{membership?.max_resolution ? ` · ${membership.max_resolution}` : ""}
</Tag>
)}
</div>
<Space>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={() => navigate("/points/recharge")}
>
立即充值
</Button>
<Button icon={<HistoryOutlined />} onClick={() => navigate("/points/transactions")}>
消费记录
</Button>
<Button icon={<CrownOutlined />} onClick={() => navigate("/subscription")}>
{isMember ? "管理订阅" : "开通会员"}
</Button>
</Space>
{balance?.member_expires_at && isMember && (
<div className={styles.expireTip}>
会员到期时间:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</div>
)}
</Card>
{/* 每日免费额度卡片 */}
<Card title="🎁 每日免费额度" className={styles.freeCard}>
{!isMember && freeLimit > 0 ? (
<>
<Progress
percent={freePercent}
strokeColor={{ "0%": "#52c41a", "100%": "#faad14" }}
format={() => `${freeUsed}/${freeLimit}`}
/>
<div className={styles.freeTip}>
<GiftOutlined /> 今日还剩 <b>{freeRemain}</b> 次免费生成额度
{dailyUsage?.reset_at && (
<span className={styles.resetTime}>
(
{new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}{" "}
重置)
</span>
)}
</div>
</>
) : isMember ? (
<Alert
type="info"
showIcon
message="会员享有更高免费额度"
description={`每日免费 ${subscription ? "(见会员权益)" : ""}生成次数,积分消耗享折扣`}
/>
) : (
<Alert
type="info"
showIcon
message="暂无可用免费额度"
description="充值或开通会员解锁更多权益"
/>
)}
</Card>
{/* 快捷提示 */}
{!isMember && rules && (
<Alert
type="warning"
showIcon
message={`非会员积分消耗倍率 ×${freeMultiplier},开通会员可享折扣`}
action={
<Button size="small" type="primary" onClick={() => navigate("/subscription")}>
开通会员
<div className="xx-points-page">
<PageHead
title="积分中心"
description="管理积分余额、查看流水、充值使用"
actions={
<Space>
<Button icon={<FileTextOutlined />} onClick={() => navigate("/points/rules")}>
积分规则
</Button>
}
style={{ marginTop: 16 }}
/>
)}
<Button
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/points/recharge")}
>
充值积分
</Button>
</Space>
}
/>
{/* 累计统计 */}
<Card title="📊 累计统计" className={styles.statsCard}>
<div className={styles.statsGrid}>
<Statistic title="累计获得" value={balance?.total_earned ?? 0} suffix="积分" />
<Statistic title="累计消耗" value={balance?.total_spent ?? 0} suffix="积分" />
</div>
{/* 顶部大卡 */}
<Card className="xx-points-hero" bordered={false}>
<Row gutter={24} align="middle">
<Col xs={24} md={14}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text type="secondary" style={{ color: "rgba(255,255,255,0.8)" }}>
当前可用积分
</Text>
<div className="xx-points-hero-balance">
<ThunderboltOutlined />
<span>{loading ? "…" : bal.toLocaleString()}</span>
</div>
<Space size={8} wrap>
{isMember ? (
<Tag color="gold" icon={<CrownFilled />} style={{ padding: "4px 10px" }}>
{memberLabel(membership, subscription)}
</Tag>
) : (
<Tag
style={{
padding: "4px 10px",
background: "rgba(255,255,255,0.2)",
color: "#fff",
border: "none",
}}
>
免费会员
</Tag>
)}
{balance?.member_expires_at && isMember && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
到期 {new Date(balance.member_expires_at).toLocaleDateString("zh-CN")}
</Text>
)}
{membership?.max_resolution && isMember && (
<Text style={{ color: "rgba(255,255,255,0.85)", fontSize: 12 }}>
· {membership.max_resolution}
</Text>
)}
{!isMember && (
<Button
size="small"
type="primary"
onClick={() => navigate("/subscription")}
style={{ background: "#fff", color: "#7c3aed", borderColor: "#fff" }}
>
<CrownFilled /> 升级会员享折扣
</Button>
)}
</Space>
</Space>
</Col>
<Col xs={24} md={10}>
{/* 今日免费额度 */}
{!isMember && freeLimit > 0 && (
<div className="xx-points-free-quota">
<div className="xx-points-free-title">
<InfoCircleOutlined /> 今日免费混剪
</div>
<Progress
percent={freeLimit > 0 ? Math.round((freeUsed / freeLimit) * 100) : 0}
strokeColor={{ "0%": "#f59e0b", "100%": "#ef4444" }}
format={() => `${freeUsed}/${freeLimit} 条`}
/>
<Text style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
剩余 {freeRemain} 条免费混剪,超出部分消耗积分
{rules?.free_user_multiplier ? `(×${rules.free_user_multiplier} 倍率)` : ""}
{dailyUsage?.reset_at && (
<span>
{" "}
·{" "}
{new Date(dailyUsage.reset_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
重置
</span>
)}
</Text>
</div>
)}
</Col>
</Row>
</Card>
{/* 统计 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计获得"
value={earned}
prefix={<ArrowUpOutlined style={{ color: "#10b981" }} />}
valueStyle={{ color: "#10b981" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="累计消耗"
value={spent}
prefix={<ArrowDownOutlined style={{ color: "#ef4444" }} />}
valueStyle={{ color: "#ef4444" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title="可用积分"
value={bal}
prefix={<ThunderboltOutlined style={{ color: "#8b5cf6" }} />}
valueStyle={{ color: "#8b5cf6" }}
/>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Statistic
title={isMember ? "会员等级" : "今日免费剩余"}
value={isMember ? memberLabel(membership, subscription) : `${freeRemain} 条`}
prefix={<CrownFilled style={{ color: "#f59e0b" }} />}
valueStyle={{ color: "#f59e0b" }}
/>
</Card>
</Col>
</Row>
{/* 快捷入口 & 最近流水 */}
<Row gutter={[16, 16]} style={{ marginTop: 8 }}>
<Col xs={24} md={14}>
<Card
title={
<Space>
<HistoryOutlined />
最近流水
</Space>
}
extra={<a onClick={() => navigate("/points/transactions")}>查看全部 →</a>}
>
{recentTx.length === 0 ? (
<Empty description="暂无积分流水" />
) : (
<List
dataSource={recentTx}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
size="small"
style={{
background: item.type === "add" ? "#d1fae5" : "#fee2e2",
color: item.type === "add" ? "#059669" : "#dc2626",
}}
icon={item.type === "add" ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
/>
}
title={sourceLabel(item.source)}
description={item.time}
/>
<div
style={{
color: item.type === "add" ? "#10b981" : "#ef4444",
fontWeight: 700,
}}
>
{item.type === "add" ? "+" : "-"}
{item.amount}
</div>
</List.Item>
)}
/>
)}
</Card>
</Col>
<Col xs={24} md={10}>
<Card title="快捷操作">
<Space direction="vertical" style={{ width: "100%" }} size={10}>
<Button
block
size="large"
type="primary"
icon={<WalletOutlined />}
onClick={() => navigate("/points/recharge")}
>
充值积分
</Button>
<Button
block
size="large"
icon={<HistoryOutlined />}
onClick={() => navigate("/points/transactions")}
>
积分明细
</Button>
<Button
block
size="large"
icon={<CrownFilled />}
onClick={() => navigate("/subscription")}
>
{isMember ? "续费/升级会员" : "升级付费会员"}
</Button>
<Button
block
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate("/points/rules")}
>
积分消耗规则
</Button>
</Space>
</Card>
</Col>
</Row>
</div>
)
}
export default PointsCenterPage
export default PointsCenter
export const Component = PointsCenter