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
257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
/**
|
||
* 积分明细页(/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
|