Files
xiaoxia-saas/apps/web/src/pages/points/Transactions.tsx
T
xiaoxia 630b64ca3b
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 / Check push changed paths (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E 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
fix(lint): resolve unused vars and any types in apps/web/src/pages/points/Transactions.tsx
2026-09-15 08:53:23 +08:00

257 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 积分明细页(/app/points/transactions)
* 分页展示积分流水,支持按类型/来源/日期/关键字筛选
* 日期筛选使用原生 Date,不引入额外依赖
*/
import React, { useEffect, useState, useCallback } from "react"
import {
Card,
Table,
Tag,
Select,
DatePicker,
Space,
Typography,
Empty,
Input,
Spin,
Button,
} from "antd"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { Dayjs } from "dayjs"
import { useNavigate } from "react-router-dom"
import PageHead from "@/components/layout/PageHead"
import { getPointsTransactions } from "@/api/points"
import type { PointsTransaction, PointsTxType } from "@/api/points/types"
import "./Points.css"
const { Text } = Typography
const { RangePicker } = DatePicker
function formatDateTime(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const TYPE_LABEL: Record<PointsTxType, { text: string; color: string }> = {
earn: { text: "获得", color: "green" },
spend: { text: "消耗", color: "red" },
refund: { text: "退还", color: "blue" },
}
const SOURCE_LABEL: 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 封面生成",
subscription_bonus: "会员赠送",
admin_adjust: "管理员调整",
refund: "失败退还",
}
const PointsTransactions: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [data, setData] = useState<PointsTransaction[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [type, setType] = useState<PointsTxType | "all">("all")
const [source, setSource] = useState<string>("all")
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
const [keyword, setKeyword] = useState("")
const load = useCallback(async () => {
setLoading(true)
try {
const params: Record<string, unknown> = { page, page_size: pageSize }
if (type !== "all") params.type = type
if (source !== "all") params.source = source
if (dateRange && dateRange[0] && dateRange[1]) {
params.start_date = dateRange[0].format("YYYY-MM-DD")
params.end_date = dateRange[1].format("YYYY-MM-DD")
}
const res = await getPointsTransactions(params)
let items = res.items
if (keyword) {
const k = keyword.toLowerCase()
items = items.filter(
(it) =>
it.description.toLowerCase().includes(k) ||
(SOURCE_LABEL[it.source] ?? it.source).includes(keyword),
)
}
setData(items)
setTotal(res.total)
} finally {
setLoading(false)
}
}, [page, pageSize, type, source, dateRange, keyword])
useEffect(() => {
load()
}, [load])
const columns: ColumnsType<PointsTransaction> = [
{
title: "时间",
dataIndex: "created_at",
width: 180,
render: (v: string) => formatDateTime(v),
},
{
title: "类型",
dataIndex: "type",
width: 90,
render: (t: PointsTxType) => {
const cfg = TYPE_LABEL[t]
return <Tag color={cfg.color}>{cfg.text}</Tag>
},
},
{
title: "来源/场景",
dataIndex: "source",
width: 160,
render: (s: string, r: PointsTransaction) => (
<Text>{r.source_name || SOURCE_LABEL[s] || s}</Text>
),
},
{
title: "说明",
dataIndex: "description",
ellipsis: true,
},
{
title: "变动",
dataIndex: "signed_amount",
width: 110,
align: "right",
render: (v: number, r: PointsTransaction) => (
<span
className={
r.type === "earn" ? "xx-tx-earn" : r.type === "refund" ? "xx-tx-refund" : "xx-tx-spend"
}
>
{v > 0 ? "+" : ""}
{v}
</span>
),
},
{
title: "余额",
dataIndex: "balance_after",
width: 110,
align: "right",
render: (v: number) => (
<Text strong style={{ fontVariantNumeric: "tabular-nums" }}>
{v}
</Text>
),
},
]
return (
<div className="xx-points-page">
<PageHead
title="积分明细"
description="查看积分收入、消耗与退还记录"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>
刷新
</Button>
<Button type="primary" onClick={() => navigate("/app/subscription")}>
充值积分
</Button>
</Space>
}
/>
<Card>
<div className="xx-points-filters">
<Space wrap size={12}>
<Select
value={type}
onChange={(v) => {
setType(v)
setPage(1)
}}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "earn", label: "获得" },
{ value: "spend", label: "消耗" },
{ value: "refund", label: "退还" },
]}
/>
<Select
value={source}
onChange={(v) => {
setSource(v)
setPage(1)
}}
style={{ width: 170 }}
showSearch
options={[
{ value: "all", label: "全部来源" },
...Object.entries(SOURCE_LABEL).map(([k, v]) => ({ value: k, label: v })),
]}
/>
<RangePicker
value={dateRange ?? undefined}
onChange={(dates) => {
setDateRange(dates as [Dayjs | null, Dayjs | null] | null)
setPage(1)
}}
/>
<Input
placeholder="搜索说明"
prefix={<SearchOutlined />}
allowClear
style={{ width: 200 }}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1)
load()
}}
/>
</Space>
</div>
<Spin spinning={loading}>
<Table<PointsTransaction>
rowKey="id"
columns={columns}
dataSource={data}
locale={{ emptyText: <Empty description="暂无积分流水,快去使用 AI 功能吧" /> }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条记录`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
/>
</Spin>
</Card>
</div>
)
}
export default PointsTransactions
export const Component = PointsTransactions