feat(points): apps/web/src/pages/points/Transactions.tsx
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user