feat: cost management CRUD + monthly summary + expiry alerts
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 0s

This commit is contained in:
WangDL 2026-05-22 15:39:30 +08:00
parent 49e1b10f67
commit 22e716129e
2 changed files with 147 additions and 18 deletions

View File

@ -1,30 +1,149 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App } from 'antd' import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App, Table, Modal, Form, Input, Select, DatePicker, InputNumber } from 'antd'
import { DollarOutlined, ReloadOutlined, LinkOutlined } from '@ant-design/icons' import { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { getBilling, type BillingInfo } from '@/services/billing-api' import { getBilling, type BillingInfo } from '@/services/billing-api'
const { Text } = Typography import { getCostSummary, createCost, updateCost, deleteCost, type CostSummary, type CostItem } from '@/services/costs-api'
function BillingCard({ p }: { p: BillingInfo }) { import dayjs from 'dayjs'
const color = p.status === 'ok' ? (parseFloat(p.balance) < 10 ? '#faad14' : '#52c41a') : '#999'
return ( const { Text, Title } = Typography
<Card title={<Space><DollarOutlined />{p.name}<Tag color={p.status === 'ok' ? 'green' : 'default'}>{p.status === 'ok' ? '正常' : '未知'}</Tag></Space>}
extra={<Button type="link" size="small" icon={<LinkOutlined />} href={p.consoleUrl} target="_blank"></Button>}> // ── API ──
<Statistic title="余额" value={p.balance} valueStyle={{ color, fontSize: 28 }} suffix={<Text type="secondary" style={{ fontSize: 14 }}>{p.currency}</Text>} />
<div style={{ marginTop: 8 }}><Text type="secondary" style={{ fontSize: 12 }}>: {p.model}</Text><br /><Text type="secondary" style={{ fontSize: 12 }}>{p.note}</Text></div> const CATEGORIES = [
</Card> { label: '服务器', value: 'server' },
) { label: '域名', value: 'domain' },
} { label: '订阅', value: 'subscription' },
{ label: 'API', value: 'api' },
{ label: '其他', value: 'other' },
]
function BillingContent() { function BillingContent() {
const qc = useQueryClient(); const [refreshing, setRefreshing] = useState(false) const { modal, message } = App.useApp()
const { data } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 }) const qc = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<CostItem | null>(null)
const [form] = Form.useForm()
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 })
const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: getCostSummary, 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 saveCost = async (values: any) => {
const body = { ...values, purchaseDate: values.purchaseDate?.toISOString(), expiryDate: values.expiryDate?.toISOString() }
try {
if (editing) await updateCost(editing.id, body)
else await createCost(body)
message.success(editing ? '已更新' : '已添加')
setModalOpen(false); setEditing(null); form.resetFields(); qc.invalidateQueries({ queryKey: ['costs', 'summary'] })
} catch { message.error('操作失败') }
}
const deleteCost = (item: CostItem) => modal.confirm({
title: '删除费用', content: `确定删除「${item.name}」?`, okType: 'danger', okText: '删除',
onOk: async () => { await deleteCost(item.id); qc.invalidateQueries({ queryKey: ['costs', 'summary'] }) },
})
const openEdit = (item: CostItem) => { setEditing(item); form.setFieldsValue({ ...item, purchaseDate: dayjs(item.purchaseDate), expiryDate: item.expiryDate ? dayjs(item.expiryDate) : null }); setModalOpen(true) }
const providers = billing?.providers || []
const summary: CostSummary = costs || { totalMonthly: 0, totalYearly: 0, totalOneTime: 0, items: [], byMonth: [], expiringSoon: [] }
const monthColumns = [
{ title: '月份', dataIndex: 'month', width: 100 },
{ title: '项目', dataIndex: 'items', render: (items: { name: string; amount: number }[]) => items.map(i => <div key={i.name}>{i.name} <Text type="secondary">¥{i.amount}</Text></div>) },
{ title: '合计', dataIndex: 'total', width: 100, render: (v: number) => <Text strong>¥{v.toLocaleString()}</Text> },
]
const expiryColumns = [
{ title: '项目', dataIndex: 'name', ellipsis: true },
{ title: '剩余', dataIndex: 'daysLeft', width: 80, render: (d: number) => <Tag color={d <= 7 ? 'red' : 'orange'}>{d}</Tag> },
{ title: '到期', dataIndex: 'expiryDate', width: 100, render: (d: string) => d ? dayjs(d).format('MM-DD') : '-' },
{ title: '金额', dataIndex: 'amount', width: 80, render: (v: number) => `¥${v}` },
]
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={5} style={{ margin: 0 }}><DollarOutlined /> API </Typography.Title> <Title level={5} style={{ margin: 0 }}><DollarOutlined /> </Title>
<Button icon={<ReloadOutlined spin={refreshing} />} onClick={async () => { setRefreshing(true); await qc.invalidateQueries({ queryKey: ['billing'] }); setTimeout(() => setRefreshing(false), 800) }} loading={refreshing}></Button> <Space>
<Button icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true) }}></Button>
<Button icon={<ReloadOutlined spin={refreshing} />} onClick={refresh} loading={refreshing}></Button>
</Space>
</div> </div>
<Row gutter={[16, 16]}>{(data?.providers || []).map(p => <Col xs={24} sm={12} lg={6} key={p.name}><BillingCard p={p} /></Col>)}</Row>
{/* API 卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{providers.map((p: BillingInfo) => (
<Col xs={24} sm={12} lg={6} key={p.name}>
<Card size="small" title={<Space><DollarOutlined />{p.name}<Tag color={p.status === 'ok' ? 'green' : 'default'}>{p.status === 'ok' ? '正常' : '未知'}</Tag></Space>}
extra={<Button type="link" size="small" href={p.consoleUrl} target="_blank"><LinkOutlined /></Button>}>
<Statistic title="余额" value={p.balance} valueStyle={{ color: p.status === 'ok' ? '#52c41a' : '#999', fontSize: 22 }} suffix={p.currency} />
</Card>
</Col>
))}
</Row>
{/* 统计数字 */}
<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
const isExpired = daysLeft !== null && daysLeft <= 0
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>
}>
<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>
<Tag>{item.billingCycle === 'monthly' ? '月付' : item.billingCycle === 'yearly' ? '年付' : '一次性'}</Tag>
{item.expiryDate && <Tag color={isExpired ? 'red' : (daysLeft && daysLeft <= 30 ? 'orange' : 'green')}>{isExpired ? '已过期' : `${daysLeft}天后到期`}</Tag>}
</div>
</Card>
</Col>
)
})}
</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>
</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>
<Form.Item name="category" label="分类" initialValue="other"><Select options={CATEGORIES} /></Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><InputNumber min={0} prefix="¥" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="billingCycle" label="计费周期" initialValue="once"><Select options={[{ label: '一次性', value: 'once' }, { label: '月付', value: 'monthly' }, { label: '年付', value: 'yearly' }]} /></Form.Item>
<Form.Item name="purchaseDate" label="购买日期" rules={[{ required: true }]}><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="expiryDate" label="到期日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="note" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
</div> </div>
) )
} }
export default function BillingPage() { return <App><BillingContent /></App> } export default function BillingPage() { return <App><BillingContent /></App> }

10
src/services/costs-api.ts Normal file
View File

@ -0,0 +1,10 @@
import { api } from './http-client'
export interface CostItem { id: string; name: string; category: string; amount: number; currency: string; purchaseDate: string; expiryDate: string | null; billingCycle: string; note: string | null }
export interface CostSummary { totalMonthly: number; totalYearly: number; totalOneTime: number; items: CostItem[]; byMonth: { month: string; total: number; items: { name: string; amount: number }[] }[]; expiringSoon: (CostItem & { daysLeft: number })[] }
export function getCostSummary(): Promise<CostSummary> { return api.get('/admin-api/costs/summary') }
export function listCosts(): Promise<CostItem[]> { return api.get('/admin-api/costs') }
export function createCost(data: Omit<CostItem, 'id' | 'currency'> & { currency?: string }): Promise<CostItem> { return api.post('/admin-api/costs', data) }
export function updateCost(id: string, data: Partial<CostItem>): Promise<CostItem> { return api.patch(`/admin-api/costs/${id}`, data) }
export function deleteCost(id: string): Promise<void> { return api.delete(`/admin-api/costs/${id}`) }