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
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:
parent
6812d8038d
commit
92653385ea
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user