feat(points): apps/web/src/pages/points/Transactions.tsx

This commit is contained in:
2026-09-15 08:39:02 +08:00
parent de5f505e01
commit cec11a1680
+256
View File
@@ -0,0 +1,256 @@
/**
* 积分明细页(/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 as any}
onChange={(v: any) => {
setDateRange(v)
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