feat: M1-06 billing page — AI cost report tab + CSV export
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 9s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 11:04:00 +08:00
parent 6812d8038d
commit 92653385ea

View File

@ -1,15 +1,14 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App, Table, Modal, Form, Input, Select, DatePicker, InputNumber } from 'antd'
import { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App, Table, Modal, Form, Input, Select, DatePicker, InputNumber, Tabs } from 'antd'
import { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons'
import { getBilling, type BillingInfo } from '@/services/billing-api'
import { getCostSummary, createCost, updateCost, deleteCost as deleteCostApi, type CostSummary, type CostItem } from '@/services/costs-api'
import { api } from '@/services/http-client'
import dayjs from 'dayjs'
const { Text, Title } = Typography
// ── API ──
const CATEGORIES = [
{ label: '服务器', value: 'server' },
{ label: '域名', value: 'domain' },
@ -28,8 +27,13 @@ function BillingContent() {
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 })
const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: getCostSummary, staleTime: 30_000 })
const { data: aiReport } = useQuery({ queryKey: ['costs', 'report'], queryFn: (): Promise<any> => api.get('/admin-api/costs/report?days=30'), staleTime: 30_000 })
const { data: topUsers } = useQuery({ queryKey: ['costs', 'top-users'], queryFn: (): Promise<any> => api.get('/admin-api/costs/top-users?days=30&limit=10'), staleTime: 30_000 })
const refresh = async () => { setRefreshing(true); await Promise.all([qc.invalidateQueries({ queryKey: ['billing'] }), qc.invalidateQueries({ queryKey: ['costs', 'summary'] })]); setTimeout(() => setRefreshing(false), 800) }
const refresh = async () => { setRefreshing(true); await qc.invalidateQueries({ queryKey: ['billing'] }); await qc.invalidateQueries({ queryKey: ['costs'] }); setTimeout(() => setRefreshing(false), 800) }
const handleAggregate = async () => { await api.post('/admin-api/costs/aggregate'); message.success('已汇总'); qc.invalidateQueries({ queryKey: ['costs'] }) }
const handleExportCsv = () => { window.open('/admin-api/costs/export-csv?days=30', '_blank') }
const saveCost = async (values: any) => {
const body = { ...values, purchaseDate: values.purchaseDate?.toISOString(), expiryDate: values.expiryDate?.toISOString() }
@ -64,17 +68,31 @@ function BillingContent() {
{ title: '金额', dataIndex: 'amount', width: 80, render: (v: number) => `¥${v}` },
]
const providerCostCols = [
{ title: 'Provider', dataIndex: 'provider', width: 120 },
{ title: '调用量', dataIndex: 'calls', width: 90, align: 'center' as const },
{ title: 'Tokens', dataIndex: 'tokens', width: 100, align: 'center' as const, render: (v: number) => v.toLocaleString() },
{ title: '成本', dataIndex: 'cost', width: 100, align: 'center' as const, render: (v: string) => <Text strong>${v}</Text> },
]
const topUserCols = [
{ title: '用户', dataIndex: 'userId', width: 180, ellipsis: true },
{ title: '调用量', dataIndex: 'calls', width: 80, align: 'center' as const },
{ title: 'Tokens', dataIndex: 'tokens', width: 100, align: 'center' as const, render: (v: number) => v.toLocaleString() },
{ title: '成本', dataIndex: 'cost', width: 100, align: 'center' as const, render: (v: string) => <Text type="danger">${v}</Text> },
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><DollarOutlined /> </Title>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleExportCsv} size="small">CSV</Button>
<Button icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true) }}></Button>
<Button icon={<ReloadOutlined spin={refreshing} />} onClick={refresh} loading={refreshing}></Button>
</Space>
</div>
{/* API 卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{providers.map((p: BillingInfo) => (
<Col xs={24} sm={12} lg={6} key={p.name}>
@ -86,15 +104,16 @@ function BillingContent() {
))}
</Row>
{/* 统计数字 */}
<Tabs items={[
{
key: 'expenses', label: '费用明细',
children: (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}><Card size="small"><Statistic title="预估月费" prefix="¥" value={summary.totalMonthly} precision={0} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="年费" prefix="¥" value={summary.totalYearly} precision={0} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="一次性" prefix="¥" value={summary.totalOneTime} precision={0} /></Card></Col>
</Row>
{/* 自定义费用卡片 */}
<Title level={5} style={{ fontSize: 14, marginBottom: 8 }}></Title>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{summary.items.map((item: CostItem) => {
const daysLeft = item.expiryDate ? Math.ceil((new Date(item.expiryDate).getTime() - Date.now()) / 86400000) : null
@ -102,12 +121,7 @@ function BillingContent() {
return (
<Col xs={24} sm={12} lg={6} key={item.id}>
<Card size="small" title={item.name}
extra={
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(item)} />
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteCost(item)} />
</Space>
}>
extra={<Space size={4}><Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(item)} /><Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteCost(item)} /></Space>}>
<Statistic title="金额" prefix="¥" value={item.amount} precision={0} valueStyle={{ fontSize: 20 }} />
<div style={{ marginTop: 4 }}>
<Tag color="blue">{CATEGORIES.find(c => c.value === item.category)?.label || item.category}</Tag>
@ -119,18 +133,31 @@ function BillingContent() {
)
})}
</Row>
{/* 到期提醒 + 月度汇总 */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card size="small" title="即将到期"><Table dataSource={summary.expiringSoon} columns={expiryColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: '暂无即将到期的费用' }} /></Card>
</Col>
<Col xs={24} lg={12}>
<Card size="small" title="月度支出"><Table dataSource={summary.byMonth} columns={monthColumns} rowKey="month" size="small" pagination={false} /></Card>
</Col>
<Col xs={24} lg={12}><Card size="small" title="即将到期"><Table dataSource={summary.expiringSoon} columns={expiryColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: '暂无即将到期的费用' }} /></Card></Col>
<Col xs={24} lg={12}><Card size="small" title="月度支出"><Table dataSource={summary.byMonth} columns={monthColumns} rowKey="month" size="small" pagination={false} /></Card></Col>
</Row>
</>
),
},
{
key: 'ai', label: <><CloudOutlined /> AI </>,
children: (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}><Card size="small"><Statistic title="AI 总成本" prefix="$" value={aiReport?.totalCost || '0'} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="AI 总调用" value={aiReport?.totalCalls || 0} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="周期" value={`${aiReport?.period || '30 days'}`} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}><Card size="small" title="按 Provider"><Table dataSource={aiReport?.byProvider || []} columns={providerCostCols} rowKey="provider" pagination={false} size="small" /></Card></Col>
<Col xs={24} lg={12}><Card size="small" title="Top 消耗用户" extra={<Button size="small" onClick={handleAggregate}></Button>}><Table dataSource={topUsers?.top || []} columns={topUserCols} rowKey="userId" pagination={false} size="small" /></Card></Col>
</Row>
</>
),
},
]} />
{/* Add/Edit Modal */}
<Modal title={editing ? '编辑费用' : '新增费用'} open={modalOpen} onCancel={() => { setModalOpen(false); setEditing(null) }} onOk={() => form.submit()} okText="保存">
<Form form={form} layout="vertical" onFinish={saveCost}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>