From 22e716129ef0b2f7e25bb1cf1c192e8b03cd5aa3 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 15:39:30 +0800 Subject: [PATCH] feat: cost management CRUD + monthly summary + expiry alerts --- src/pages/Billing.tsx | 155 +++++++++++++++++++++++++++++++++----- src/services/costs-api.ts | 10 +++ 2 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 src/services/costs-api.ts diff --git a/src/pages/Billing.tsx b/src/pages/Billing.tsx index 3f0dcf1..05995d3 100644 --- a/src/pages/Billing.tsx +++ b/src/pages/Billing.tsx @@ -1,30 +1,149 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App } from 'antd' -import { DollarOutlined, ReloadOutlined, LinkOutlined } from '@ant-design/icons' +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 { getBilling, type BillingInfo } from '@/services/billing-api' -const { Text } = Typography -function BillingCard({ p }: { p: BillingInfo }) { - const color = p.status === 'ok' ? (parseFloat(p.balance) < 10 ? '#faad14' : '#52c41a') : '#999' - return ( - {p.name}{p.status === 'ok' ? '正常' : '未知'}} - extra={}> - {p.currency}} /> -
模型: {p.model}
{p.note}
-
- ) -} +import { getCostSummary, createCost, updateCost, deleteCost, type CostSummary, type CostItem } from '@/services/costs-api' +import dayjs from 'dayjs' + +const { Text, Title } = Typography + +// ── API ── + +const CATEGORIES = [ + { label: '服务器', value: 'server' }, + { label: '域名', value: 'domain' }, + { label: '订阅', value: 'subscription' }, + { label: 'API', value: 'api' }, + { label: '其他', value: 'other' }, +] + function BillingContent() { - const qc = useQueryClient(); const [refreshing, setRefreshing] = useState(false) - const { data } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 }) + const { modal, message } = App.useApp() + const qc = useQueryClient() + const [refreshing, setRefreshing] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(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 =>
{i.name} ¥{i.amount}
) }, + { title: '合计', dataIndex: 'total', width: 100, render: (v: number) => ¥{v.toLocaleString()} }, + ] + + const expiryColumns = [ + { title: '项目', dataIndex: 'name', ellipsis: true }, + { title: '剩余', dataIndex: 'daysLeft', width: 80, render: (d: number) => {d}天 }, + { title: '到期', dataIndex: 'expiryDate', width: 100, render: (d: string) => d ? dayjs(d).format('MM-DD') : '-' }, + { title: '金额', dataIndex: 'amount', width: 80, render: (v: number) => `¥${v}` }, + ] + return (
- API 用量 - + <DollarOutlined /> 费用总览 + + + +
- {(data?.providers || []).map(p => )} + + {/* API 卡片 */} + + {providers.map((p: BillingInfo) => ( + + {p.name}{p.status === 'ok' ? '正常' : '未知'}} + extra={}> + + + + ))} + + + {/* 统计数字 */} + + + + + + + {/* 自定义费用卡片 */} + 费用明细 + + {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 ( + + +