1、增加订单开发票功能和发票抬头添加功能
2、一个订单只能在一个开票里,不允许多开
This commit is contained in:
@@ -42,6 +42,7 @@ import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
||||
import AdminApiKeys from './pages/AdminApiKeys';
|
||||
import AdminApiModelPricings from './pages/AdminApiModelPricings';
|
||||
import AdminApiUsage from './pages/AdminApiUsage';
|
||||
import AdminInvoices from './pages/AdminInvoices';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -106,6 +107,7 @@ const App = () => {
|
||||
<Route path="api-keys" element={<AdminApiKeys />} />
|
||||
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
|
||||
<Route path="api-usage" element={<AdminApiUsage />} />
|
||||
<Route path="invoices" element={<AdminInvoices />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
|
||||
@@ -568,6 +568,40 @@ export async function refundPaymentOrder(orderNo: string): Promise<void> {
|
||||
await api.post(`/admin/payment-orders/${orderNo}/refund`);
|
||||
}
|
||||
|
||||
// ── Invoice Management ───────────────────────────────────
|
||||
|
||||
export async function getAdminInvoices(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
phone?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{ items: any[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.phone) qs.set('phone', params.phone);
|
||||
if (params?.startDate) qs.set('start_date', params.startDate);
|
||||
if (params?.endDate) qs.set('end_date', params.endDate);
|
||||
return api.get(`/admin/invoices?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function getAdminInvoiceDetail(id: string): Promise<any> {
|
||||
return api.get(`/admin/invoices/${id}`);
|
||||
}
|
||||
|
||||
export async function updateInvoiceStatus(id: string, data: {
|
||||
status: 'success' | 'failed';
|
||||
failureReason?: string;
|
||||
}): Promise<void> {
|
||||
await api.put(`/admin/invoices/${id}/status`, {
|
||||
status: data.status,
|
||||
failure_reason: data.failureReason,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import type { InvoiceItem, InvoiceDetail } from '../types';
|
||||
|
||||
const AdminInvoices: React.FC = () => {
|
||||
const [data, setData] = useState<InvoiceItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [phoneFilter, setPhoneFilter] = useState<string>('');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
|
||||
// 详情弹窗
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
|
||||
|
||||
// 失败原因弹窗
|
||||
const [failModalOpen, setFailModalOpen] = useState(false);
|
||||
const [failReason, setFailReason] = useState('');
|
||||
const [failTargetId, setFailTargetId] = useState<string | null>(null);
|
||||
const [failSubmitting, setFailSubmitting] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminInvoices({
|
||||
page,
|
||||
pageSize,
|
||||
status: statusFilter || undefined,
|
||||
phone: phoneFilter || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
setData(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [page, pageSize, statusFilter, startDate, endDate]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleViewDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailModalOpen(true);
|
||||
try {
|
||||
const detail = await getAdminInvoiceDetail(id);
|
||||
setCurrentDetail(detail);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取详情失败');
|
||||
setDetailModalOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkSuccess = async (id: string) => {
|
||||
try {
|
||||
await updateInvoiceStatus(id, { status: 'success' });
|
||||
message.success('已标记为开具成功');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFailModal = (id: string) => {
|
||||
setFailTargetId(id);
|
||||
setFailReason('');
|
||||
setFailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmFail = async () => {
|
||||
if (!failReason.trim()) {
|
||||
message.warning('请填写失败原因');
|
||||
return;
|
||||
}
|
||||
if (!failTargetId) return;
|
||||
setFailSubmitting(true);
|
||||
try {
|
||||
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
|
||||
message.success('已标记为开具失败');
|
||||
setFailModalOpen(false);
|
||||
setFailTargetId(null);
|
||||
setFailReason('');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
setFailSubmitting(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const statusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; label: string }> = {
|
||||
processing: { color: 'blue', label: '开具中' },
|
||||
success: { color: 'green', label: '已开具' },
|
||||
failed: { color: 'red', label: '已失败' },
|
||||
};
|
||||
const c = config[status] || { color: 'default', label: status };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '发票编号',
|
||||
dataIndex: 'invoiceNo',
|
||||
key: 'invoiceNo',
|
||||
width: 180,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 100,
|
||||
render: (_: string, record: InvoiceItem) => (
|
||||
<div>
|
||||
<div><Typography.Text strong>{record.username}</Typography.Text></div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '抬头类型',
|
||||
dataIndex: 'headerType',
|
||||
key: 'headerType',
|
||||
width: 80,
|
||||
render: (v: string) => (v === 'company' ? '企业' : '个人'),
|
||||
},
|
||||
{
|
||||
title: '抬头名称',
|
||||
dataIndex: 'headerName',
|
||||
key: 'headerName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'totalAmount',
|
||||
key: 'totalAmount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
dataIndex: 'orderCount',
|
||||
key: 'orderCount',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => statusTag(v),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: InvoiceItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'processing' && (
|
||||
<>
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
|
||||
开具成功
|
||||
</Button>
|
||||
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
|
||||
开具失败
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>发票管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条记录</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={statusFilter === null ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter(null); setPage(1); }}
|
||||
icon={<FilterOutlined />}
|
||||
size="small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'processing' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('processing'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
开具中
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'success' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('success'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已开具
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'failed' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('failed'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已失败
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="按用户手机号搜索"
|
||||
value={phoneFilter}
|
||||
onChange={(e) => setPhoneFilter(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="开始日期"
|
||||
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD') : '')}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="结束日期"
|
||||
onChange={(d) => setEndDate(d ? d.format('YYYY-MM-DD') : '')}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />发票详情</Space>}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : currentDetail && (
|
||||
<div style={{ padding: 8 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
{currentDetail.invoiceNo}
|
||||
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#64748b' }}>用户ID:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.userId}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头类型:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头名称:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerName}</Typography.Text>
|
||||
{currentDetail.headerTaxNo && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>税号:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text style={{ color: '#64748b' }}>接收邮箱:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.email}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>总金额:</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>创建时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
|
||||
{currentDetail.issuedAt && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>开票时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
{currentDetail.failureReason && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>失败原因:</Typography.Text>
|
||||
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关联订单 */}
|
||||
{currentDetail.orders && currentDetail.orders.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
关联订单({currentDetail.orders.length} 笔)
|
||||
</Typography.Text>
|
||||
<Table
|
||||
dataSource={currentDetail.orders}
|
||||
columns={[
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
key: 'orderNo',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'credits',
|
||||
key: 'credits',
|
||||
align: 'right' as const,
|
||||
},
|
||||
]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 失败原因弹窗 */}
|
||||
<Modal
|
||||
title="开具失败"
|
||||
open={failModalOpen}
|
||||
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
|
||||
onOk={handleConfirmFail}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
confirmLoading={failSubmitting}
|
||||
>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
|
||||
请填写失败原因(用户可见):
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={failReason}
|
||||
onChange={(e) => setFailReason(e.target.value)}
|
||||
placeholder="例如:抬头信息有误,请重新提交"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInvoices;
|
||||
@@ -1442,3 +1442,52 @@ export interface VideoUpscaleConfigSavePayload {
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Invoice Types ───────────────────────────────────────
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
phone: string;
|
||||
headerType: string;
|
||||
headerName: string;
|
||||
email: string;
|
||||
totalAmount: number;
|
||||
totalCredits: number;
|
||||
orderCount: number;
|
||||
status: string;
|
||||
failureReason: string | null;
|
||||
issuedAt: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface InvoiceOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
userId: string;
|
||||
headerType: string;
|
||||
headerName: string;
|
||||
headerTaxNo: string | null;
|
||||
headerRegisterAddress: string | null;
|
||||
headerRegisterPhone: string | null;
|
||||
headerBankName: string | null;
|
||||
headerBankAccount: string | null;
|
||||
email: string;
|
||||
totalAmount: number;
|
||||
totalCredits: number;
|
||||
status: string;
|
||||
failureReason: string | null;
|
||||
issuedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
orders: InvoiceOrder[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""发票管理表迁移
|
||||
|
||||
创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。
|
||||
|
||||
Revision ID: 20260810_20260810
|
||||
Revises: 2026080601
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '20260810_20260810'
|
||||
down_revision: Union[str, None] = '2026080601'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# 1. 创建 invoices 表
|
||||
# ============================================================
|
||||
op.create_table(
|
||||
'invoices',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
|
||||
sa.Column('header_type', sa.String(16), nullable=False),
|
||||
sa.Column('header_name', sa.String(128), nullable=False),
|
||||
sa.Column('header_tax_no', sa.String(32), nullable=True),
|
||||
sa.Column('header_register_address', sa.String(256), nullable=True),
|
||||
sa.Column('header_register_phone', sa.String(32), nullable=True),
|
||||
sa.Column('header_bank_name', sa.String(128), nullable=True),
|
||||
sa.Column('header_bank_account', sa.String(64), nullable=True),
|
||||
sa.Column('email', sa.String(128), nullable=False),
|
||||
sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
|
||||
sa.Column('failure_reason', sa.Text, nullable=True),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
|
||||
op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
|
||||
op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
|
||||
|
||||
# ============================================================
|
||||
# 2. 创建 invoice_orders 表
|
||||
# ============================================================
|
||||
op.create_table(
|
||||
'invoice_orders',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('order_no', sa.String(64), nullable=False),
|
||||
sa.Column('amount', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('credits', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
|
||||
op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
|
||||
op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
|
||||
|
||||
# ============================================================
|
||||
# 3. 表注释和字段注释
|
||||
# ============================================================
|
||||
_comment_table('invoices', '发票主表')
|
||||
_comment_column('invoices', 'id', '主键')
|
||||
_comment_column('invoices', 'user_id', '申请用户ID')
|
||||
_comment_column('invoices', 'invoice_no', '发票编号')
|
||||
_comment_column('invoices', 'header_type', '抬头类型: personal/company')
|
||||
_comment_column('invoices', 'header_name', '抬头名称')
|
||||
_comment_column('invoices', 'header_tax_no', '税号')
|
||||
_comment_column('invoices', 'header_register_address', '注册地址')
|
||||
_comment_column('invoices', 'header_register_phone', '注册电话')
|
||||
_comment_column('invoices', 'header_bank_name', '开户行')
|
||||
_comment_column('invoices', 'header_bank_account', '银行账号')
|
||||
_comment_column('invoices', 'email', '电子邮箱(必填)')
|
||||
_comment_column('invoices', 'total_amount', '开票总金额')
|
||||
_comment_column('invoices', 'total_credits', '总积分')
|
||||
_comment_column('invoices', 'status', '状态: processing/success/failed')
|
||||
_comment_column('invoices', 'failure_reason', '失败原因')
|
||||
_comment_column('invoices', 'issued_at', '开票成功时间')
|
||||
|
||||
_comment_table('invoice_orders', '发票-订单关联表')
|
||||
_comment_column('invoice_orders', 'id', '主键')
|
||||
_comment_column('invoice_orders', 'invoice_id', '发票ID')
|
||||
_comment_column('invoice_orders', 'order_id', '订单ID')
|
||||
_comment_column('invoice_orders', 'order_no', '订单号(冗余)')
|
||||
_comment_column('invoice_orders', 'amount', '订单金额(冗余)')
|
||||
_comment_column('invoice_orders', 'credits', '订单积分(冗余)')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('invoice_orders')
|
||||
op.drop_table('invoices')
|
||||
@@ -35,6 +35,7 @@ from app.api.v1.material_admin import router as material_admin_router
|
||||
from app.api.v1.private_portrait import router as private_portrait_router
|
||||
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||
from app.api.v1.upload_resource import router as upload_resource_router
|
||||
from app.api.v1.invoices import router as invoices_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -72,3 +73,4 @@ api_router.include_router(material_admin_router)
|
||||
api_router.include_router(private_portrait_router)
|
||||
api_router.include_router(private_portrait_virtual_router)
|
||||
api_router.include_router(upload_resource_router)
|
||||
api_router.include_router(invoices_router)
|
||||
|
||||
@@ -60,6 +60,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
from app.schemas.invoice import InvoiceStatusUpdateRequest
|
||||
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
@@ -1973,11 +1974,10 @@ async def get_stats(
|
||||
)
|
||||
|
||||
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
|
||||
# 把 timestamptz 按东八区(业务时区)偏移后再转 DATE,
|
||||
# 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置。
|
||||
# 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。
|
||||
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST),
|
||||
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。
|
||||
from sqlalchemy import Date, cast as sa_cast
|
||||
_day_expr = sa_cast(CreditRecord.created_at + timedelta(hours=8), Date)
|
||||
_day_expr = sa_cast(CreditRecord.created_at, Date)
|
||||
# 图表固定展示 [date_end - 6天, date_end] 共7天
|
||||
_chart_end_dt = date_end
|
||||
_chart_start_dt = datetime(
|
||||
@@ -2487,6 +2487,118 @@ async def upload_login_video(
|
||||
return {"url": url}
|
||||
|
||||
|
||||
# ── Payment Stats ────────────────────────────────────────
|
||||
# ── Invoice Management ───────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
async def admin_list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
status: str | None = Query(None),
|
||||
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
|
||||
start_date: str | None = Query(None),
|
||||
end_date: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""后台发票列表(分页+筛选)。"""
|
||||
from app.services.invoice import get_admin_invoices
|
||||
|
||||
items, total = await get_admin_invoices(
|
||||
db, page, page_size,
|
||||
status_filter=status,
|
||||
phone=phone,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}")
|
||||
async def admin_get_invoice(
|
||||
invoice_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""后台获取发票详情(含关联订单)。"""
|
||||
from app.services.invoice import get_invoice_with_orders
|
||||
|
||||
detail = await get_invoice_with_orders(db, invoice_id)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="发票不存在")
|
||||
|
||||
invoice = detail["invoice"]
|
||||
orders = detail["orders"]
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"userId": invoice.user_id,
|
||||
"headerType": invoice.header_type,
|
||||
"headerName": invoice.header_name,
|
||||
"headerTaxNo": invoice.header_tax_no,
|
||||
"headerRegisterAddress": invoice.header_register_address,
|
||||
"headerRegisterPhone": invoice.header_register_phone,
|
||||
"headerBankName": invoice.header_bank_name,
|
||||
"headerBankAccount": invoice.header_bank_account,
|
||||
"email": invoice.email,
|
||||
"totalAmount": round(float(invoice.total_amount), 2),
|
||||
"totalCredits": round(float(invoice.total_credits), 2),
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
|
||||
"orders": [
|
||||
{
|
||||
"id": o.id,
|
||||
"orderNo": o.order_no,
|
||||
"amount": round(float(o.amount), 2),
|
||||
"credits": round(float(o.credits), 2),
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/invoices/{invoice_id}/status")
|
||||
async def admin_update_invoice_status(
|
||||
invoice_id: str,
|
||||
req: InvoiceStatusUpdateRequest,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新发票状态(success/failed)。"""
|
||||
from app.services.invoice import update_invoice_status
|
||||
|
||||
invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
|
||||
await db.flush()
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"发票状态变更: {invoice.invoice_no} {old_status} → {req.status}",
|
||||
"PUT",
|
||||
f"/admin/invoices/{invoice_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"invoice_id": invoice_id,
|
||||
"invoice_no": invoice.invoice_no,
|
||||
"old_status": old_status,
|
||||
"new_status": req.status,
|
||||
"failure_reason": req.failure_reason,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.invoice import Invoice, InvoiceOrder
|
||||
from app.models.user import User
|
||||
from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut
|
||||
from app.services.invoice import (
|
||||
create_invoice,
|
||||
get_user_invoices,
|
||||
get_invoice_by_id,
|
||||
get_invoice_with_orders,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
router = APIRouter(prefix="/invoices", tags=["invoices"])
|
||||
|
||||
|
||||
@router.post("", response_model=InvoiceOut)
|
||||
async def create_invoice_endpoint(
|
||||
req: InvoiceCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建发票申请。"""
|
||||
invoice = await create_invoice(db, current_user.id, req)
|
||||
await db.commit()
|
||||
|
||||
# 重新查询以获取关联订单
|
||||
detail = await get_invoice_with_orders(db, invoice.id)
|
||||
return _invoice_to_out(detail["invoice"], detail["orders"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的发票列表(分页)。"""
|
||||
invoices, total = await get_user_invoices(db, current_user.id, page, page_size)
|
||||
|
||||
# 加载每个发票的关联订单
|
||||
items = []
|
||||
for inv in invoices:
|
||||
result = await db.execute(
|
||||
select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
items.append(_invoice_to_out(inv, list(orders)))
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.get("/{invoice_id}")
|
||||
async def get_invoice(
|
||||
invoice_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取发票详情(含关联订单)。"""
|
||||
detail = await get_invoice_with_orders(db, invoice_id)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
|
||||
|
||||
invoice = detail["invoice"]
|
||||
if invoice.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票")
|
||||
|
||||
return _invoice_to_out(invoice, detail["orders"])
|
||||
|
||||
|
||||
def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict:
|
||||
"""将 Invoice ORM 对象转换为响应 dict。"""
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"userId": invoice.user_id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"headerType": invoice.header_type,
|
||||
"headerName": invoice.header_name,
|
||||
"headerTaxNo": invoice.header_tax_no,
|
||||
"headerRegisterAddress": invoice.header_register_address,
|
||||
"headerRegisterPhone": invoice.header_register_phone,
|
||||
"headerBankName": invoice.header_bank_name,
|
||||
"headerBankAccount": invoice.header_bank_account,
|
||||
"email": invoice.email,
|
||||
"totalAmount": round(float(invoice.total_amount), 2),
|
||||
"totalCredits": round(float(invoice.total_credits), 2),
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
|
||||
"orders": [
|
||||
{
|
||||
"id": o.id,
|
||||
"invoiceId": o.invoice_id,
|
||||
"orderId": o.order_id,
|
||||
"orderNo": o.order_no,
|
||||
"amount": round(float(o.amount), 2),
|
||||
"credits": round(float(o.credits), 2),
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
@@ -36,6 +36,7 @@ from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.invoice import Invoice, InvoiceOrder
|
||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
|
||||
|
||||
@@ -57,4 +58,5 @@ __all__ = [
|
||||
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
|
||||
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
|
||||
"ApiModelPricing",
|
||||
"Invoice", "InvoiceOrder",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Invoice(Base, TimestampMixin):
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
||||
header_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
header_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
email: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
issued_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_invoices_user_created', 'user_id', 'created_at'),
|
||||
Index('idx_invoices_status_created', 'status', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
class InvoiceOrder(Base, TimestampMixin):
|
||||
__tablename__ = "invoice_orders"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
invoice_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
order_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
|
||||
Index('idx_invoice_orders_invoice', 'invoice_id'),
|
||||
Index('idx_invoice_orders_order', 'order_id'),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$")
|
||||
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
"""创建发票请求。"""
|
||||
header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
|
||||
header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
|
||||
header_tax_no: str | None = Field(None, max_length=32, description="税号")
|
||||
header_register_address: str | None = Field(None, max_length=256, description="注册地址")
|
||||
header_register_phone: str | None = Field(None, max_length=32, description="注册电话")
|
||||
header_bank_name: str | None = Field(None, max_length=128, description="开户行")
|
||||
header_bank_account: str | None = Field(None, max_length=64, description="银行账号")
|
||||
email: str = Field(..., max_length=128, description="电子邮箱(必填)")
|
||||
order_ids: list[str] = Field(..., min_length=1, description="订单ID列表")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_email(self) -> "InvoiceCreateRequest":
|
||||
if not _EMAIL_REGEX.match(self.email):
|
||||
raise ValueError("邮箱格式不正确")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_company_fields(self) -> "InvoiceCreateRequest":
|
||||
if self.header_type == "company" and not self.header_tax_no:
|
||||
raise ValueError("企业抬头必须填写税号")
|
||||
return self
|
||||
|
||||
|
||||
class InvoiceStatusUpdateRequest(BaseModel):
|
||||
"""更新发票状态请求。"""
|
||||
status: str = Field(..., pattern="^(success|failed)$", description="目标状态")
|
||||
failure_reason: str | None = Field(None, max_length=500, description="失败原因")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest":
|
||||
if self.status == "failed" and not self.failure_reason:
|
||||
raise ValueError("开具失败时必须填写失败原因")
|
||||
return self
|
||||
|
||||
|
||||
class InvoiceOrderOut(BaseModel):
|
||||
"""发票关联订单响应。"""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: str
|
||||
invoice_id: str
|
||||
order_id: str
|
||||
order_no: str
|
||||
amount: float
|
||||
credits: float
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
"""发票响应体。"""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
invoice_no: str
|
||||
header_type: str
|
||||
header_name: str
|
||||
header_tax_no: str | None = None
|
||||
header_register_address: str | None = None
|
||||
header_register_phone: str | None = None
|
||||
header_bank_name: str | None = None
|
||||
header_bank_account: str | None = None
|
||||
email: str
|
||||
total_amount: float
|
||||
total_credits: float
|
||||
status: str
|
||||
failure_reason: str | None = None
|
||||
issued_at: NaiveDatetimeOptional = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
orders: list[InvoiceOrderOut] = []
|
||||
@@ -0,0 +1,292 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.invoice import Invoice, InvoiceOrder
|
||||
from app.models.payment_order import PaymentOrder
|
||||
from app.schemas.invoice import InvoiceCreateRequest, InvoiceStatusUpdateRequest
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _generate_invoice_no() -> str:
|
||||
"""生成发票编号:FP + YYYYMMDD + 5位随机数。"""
|
||||
now = datetime.now(CST)
|
||||
date_str = now.strftime("%Y%m%d")
|
||||
random_part = str(random.randint(10000, 99999))
|
||||
return f"FP{date_str}{random_part}"
|
||||
|
||||
|
||||
async def check_orders_available(
|
||||
db: AsyncSession,
|
||||
order_ids: list[str],
|
||||
exclude_invoice_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""检查订单是否已被其他 processing/success 发票占用。
|
||||
|
||||
返回被占用的订单列表,每项包含 order_id、order_no、invoice_no。
|
||||
"""
|
||||
stmt = (
|
||||
select(InvoiceOrder.order_id, InvoiceOrder.order_no, Invoice.invoice_no)
|
||||
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
|
||||
.where(
|
||||
InvoiceOrder.order_id.in_(order_ids),
|
||||
Invoice.status.in_(["processing", "success"]),
|
||||
)
|
||||
)
|
||||
if exclude_invoice_id:
|
||||
stmt = stmt.where(Invoice.id != exclude_invoice_id)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{"order_id": row.order_id, "order_no": row.order_no, "invoice_no": row.invoice_no}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
data: InvoiceCreateRequest,
|
||||
) -> Invoice:
|
||||
"""创建发票。校验订单归属、订单唯一性,创建主表+关联表。"""
|
||||
# 1. 查询订单并校验归属
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(PaymentOrder.id.in_(data.order_ids))
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
if len(orders) != len(data.order_ids):
|
||||
found_ids = {o.id for o in orders}
|
||||
missing = [oid for oid in data.order_ids if oid not in found_ids]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"订单不存在: {', '.join(missing)}",
|
||||
)
|
||||
|
||||
for order in orders:
|
||||
if order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"订单 {order.order_no} 不属于当前用户",
|
||||
)
|
||||
if order.status != "paid":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"订单 {order.order_no} 未支付,无法开票",
|
||||
)
|
||||
|
||||
# 2. 检查订单唯一性
|
||||
occupied = await check_orders_available(db, data.order_ids)
|
||||
if occupied:
|
||||
details = "; ".join(
|
||||
f"订单 {o['order_no']} 已被发票 {o['invoice_no']} 占用"
|
||||
for o in occupied
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=details,
|
||||
)
|
||||
|
||||
# 3. 创建发票
|
||||
total_amount = sum(float(o.amount) for o in orders)
|
||||
total_credits = sum(float(o.credits) for o in orders)
|
||||
|
||||
invoice = Invoice(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
invoice_no=_generate_invoice_no(),
|
||||
header_type=data.header_type,
|
||||
header_name=data.header_name,
|
||||
header_tax_no=data.header_tax_no,
|
||||
header_register_address=data.header_register_address,
|
||||
header_register_phone=data.header_register_phone,
|
||||
header_bank_name=data.header_bank_name,
|
||||
header_bank_account=data.header_bank_account,
|
||||
email=data.email,
|
||||
total_amount=round(total_amount, 2),
|
||||
total_credits=round(total_credits, 2),
|
||||
status="processing",
|
||||
)
|
||||
db.add(invoice)
|
||||
await db.flush()
|
||||
|
||||
# 4. 创建关联表
|
||||
for order in orders:
|
||||
io = InvoiceOrder(
|
||||
id=generate_id(),
|
||||
invoice_id=invoice.id,
|
||||
order_id=order.id,
|
||||
order_no=order.order_no,
|
||||
amount=round(float(order.amount), 2),
|
||||
credits=round(float(order.credits), 2),
|
||||
)
|
||||
db.add(io)
|
||||
|
||||
await db.flush()
|
||||
return invoice
|
||||
|
||||
|
||||
async def get_user_invoices(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[Invoice], int]:
|
||||
"""获取用户发票列表。"""
|
||||
count_query = select(func.count(Invoice.id)).where(Invoice.user_id == user_id)
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(Invoice)
|
||||
.where(Invoice.user_id == user_id)
|
||||
.order_by(Invoice.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
invoices = result.scalars().all()
|
||||
return list(invoices), total
|
||||
|
||||
|
||||
async def get_invoice_by_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
) -> Invoice | None:
|
||||
"""获取发票详情。"""
|
||||
result = await db.execute(
|
||||
select(Invoice).where(Invoice.id == invoice_id).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_invoice_with_orders(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
) -> dict | None:
|
||||
"""获取发票+关联订单详情。"""
|
||||
invoice = await get_invoice_by_id(db, invoice_id)
|
||||
if not invoice:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(InvoiceOrder)
|
||||
.where(InvoiceOrder.invoice_id == invoice_id)
|
||||
.order_by(InvoiceOrder.created_at.asc())
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
return {
|
||||
"invoice": invoice,
|
||||
"orders": list(orders),
|
||||
}
|
||||
|
||||
|
||||
async def update_invoice_status(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
data: InvoiceStatusUpdateRequest,
|
||||
admin_id: str,
|
||||
) -> Invoice:
|
||||
"""更新发票状态,记录审计日志。"""
|
||||
invoice = await get_invoice_by_id(db, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="发票不存在",
|
||||
)
|
||||
|
||||
# 终态校验
|
||||
if invoice.status in ("success", "failed"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"发票已终结({invoice.status}),无法变更",
|
||||
)
|
||||
|
||||
old_status = invoice.status
|
||||
invoice.status = data.status
|
||||
|
||||
if data.status == "success":
|
||||
invoice.issued_at = datetime.now(CST)
|
||||
invoice.failure_reason = None
|
||||
elif data.status == "failed":
|
||||
invoice.failure_reason = data.failure_reason
|
||||
invoice.issued_at = None
|
||||
|
||||
await db.flush()
|
||||
return invoice, old_status
|
||||
|
||||
|
||||
async def get_admin_invoices(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: str | None = None,
|
||||
phone: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""后台获取发票列表(含用户信息)。"""
|
||||
from app.models.user import User
|
||||
|
||||
query = select(Invoice, User.username, User.phone).join(User, Invoice.user_id == User.id)
|
||||
count_query = select(func.count(Invoice.id))
|
||||
|
||||
filters = []
|
||||
if status_filter:
|
||||
filters.append(Invoice.status == status_filter)
|
||||
if phone:
|
||||
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
|
||||
if start_date:
|
||||
filters.append(Invoice.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
|
||||
if end_date:
|
||||
filters.append(
|
||||
Invoice.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
|
||||
)
|
||||
|
||||
for f in filters:
|
||||
query = query.where(f)
|
||||
count_query = count_query.where(f)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
query.order_by(Invoice.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for invoice, username, user_phone in rows:
|
||||
# 获取关联订单数
|
||||
order_count_result = await db.execute(
|
||||
select(func.count(InvoiceOrder.id)).where(InvoiceOrder.invoice_id == invoice.id)
|
||||
)
|
||||
order_count = order_count_result.scalar() or 0
|
||||
|
||||
items.append({
|
||||
"id": invoice.id,
|
||||
"invoiceNo": invoice.invoice_no,
|
||||
"userId": invoice.user_id,
|
||||
"username": username,
|
||||
"phone": user_phone,
|
||||
"headerType": invoice.header_type,
|
||||
"headerName": invoice.header_name,
|
||||
"email": invoice.email,
|
||||
"totalAmount": round(float(invoice.total_amount), 2),
|
||||
"totalCredits": round(float(invoice.total_credits), 2),
|
||||
"orderCount": order_count,
|
||||
"status": invoice.status,
|
||||
"failureReason": invoice.failure_reason,
|
||||
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
})
|
||||
|
||||
return items, total
|
||||
@@ -460,6 +460,43 @@ export async function cancelPaymentOrder(orderNo: string): Promise<void> {
|
||||
return api.post(`/payments/orders/${orderNo}/cancel`);
|
||||
}
|
||||
|
||||
// ── Invoices ──────────────────────────────────────────────
|
||||
|
||||
export async function createInvoice(data: {
|
||||
headerType: 'personal' | 'company';
|
||||
headerName: string;
|
||||
headerTaxNo?: string;
|
||||
headerRegisterAddress?: string;
|
||||
headerRegisterPhone?: string;
|
||||
headerBankName?: string;
|
||||
headerBankAccount?: string;
|
||||
email: string;
|
||||
orderIds: string[];
|
||||
}): Promise<any> {
|
||||
return api.post('/invoices', {
|
||||
header_type: data.headerType,
|
||||
header_name: data.headerName,
|
||||
header_tax_no: data.headerTaxNo,
|
||||
header_register_address: data.headerRegisterAddress,
|
||||
header_register_phone: data.headerRegisterPhone,
|
||||
header_bank_name: data.headerBankName,
|
||||
header_bank_account: data.headerBankAccount,
|
||||
email: data.email,
|
||||
order_ids: data.orderIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvoices(params?: { page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
return api.get(`/invoices?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function getInvoiceDetail(id: string): Promise<any> {
|
||||
return api.get(`/invoices/${id}`);
|
||||
}
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/credits/ratios');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Tag, Empty, Spin, Pagination, Button, Typography, Modal, Form, Input, Radio, message, Space } from 'antd';
|
||||
import { FileTextOutlined, PlusOutlined, CloseOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
|
||||
import { getPaymentOrders } from '../api';
|
||||
import { getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail } from '../api';
|
||||
|
||||
interface InvoiceRecord {
|
||||
id: string;
|
||||
@@ -37,6 +37,7 @@ const mockHeaders: InvoiceHeader[] = [
|
||||
|
||||
const InvoicePage: React.FC = () => {
|
||||
const [records, setRecords] = useState<InvoiceRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [headers, setHeaders] = useState<InvoiceHeader[]>([]);
|
||||
const [headerModalOpen, setHeaderModalOpen] = useState(false);
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
@@ -47,6 +48,8 @@ const InvoicePage: React.FC = () => {
|
||||
const [selectedOrdersMap, setSelectedOrdersMap] = useState<Map<string, any>>(new Map());
|
||||
const [selectHeaderModalOpen, setSelectHeaderModalOpen] = useState(false);
|
||||
const [selectedHeaderId, setSelectedHeaderId] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 订单列表(从接口获取)
|
||||
const [orderLoading, setOrderLoading] = useState(false);
|
||||
@@ -87,6 +90,37 @@ const InvoicePage: React.FC = () => {
|
||||
setOrderLoading(false);
|
||||
};
|
||||
|
||||
// 加载发票记录
|
||||
const loadInvoices = async () => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const data = await getInvoices({ page: 1, pageSize: 50 });
|
||||
const items = (data.items || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
orderNo: item.invoiceNo,
|
||||
orderType: item.headerType === 'company' ? '企业' : '个人',
|
||||
amount: item.totalAmount,
|
||||
createdAt: item.createdAt,
|
||||
// 后端状态映射:processing→processing, success→issued, failed→rejected
|
||||
status: item.status === 'success' ? 'issued' : item.status === 'failed' ? 'rejected' : 'processing',
|
||||
headerName: item.headerName,
|
||||
headerType: item.headerType,
|
||||
headerTaxNo: item.headerTaxNo,
|
||||
selectedOrders: item.orders || [],
|
||||
email: item.email,
|
||||
failureReason: item.failureReason,
|
||||
}));
|
||||
setRecords(items);
|
||||
} catch {
|
||||
// 静默失败,保留空列表
|
||||
}
|
||||
setRecordsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (issueModalOpen) {
|
||||
loadOrderData(1);
|
||||
@@ -125,10 +159,10 @@ const InvoicePage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '开具时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
render: (text: string) => text ? <span style={{ color: '#64748b' }}>{new Date(text).toLocaleString('zh-CN')}</span> : '-',
|
||||
},
|
||||
{
|
||||
title: '发票状态',
|
||||
@@ -395,33 +429,50 @@ const InvoicePage: React.FC = () => {
|
||||
setSelectHeaderModalOpen(true);
|
||||
};
|
||||
|
||||
// 开票流程:选择抬头后确认 → 计算总金额,添加到开票记录
|
||||
const handleHeaderConfirm = () => {
|
||||
// 开票流程:选择抬头后确认 → 调用后端 API 提交开票
|
||||
const handleHeaderConfirm = async () => {
|
||||
if (!selectedHeaderId) {
|
||||
message.warning('请选择一个发票抬头');
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
message.warning('请输入电子邮箱');
|
||||
return;
|
||||
}
|
||||
const emailRegex = /^[\w.\-]+@[\w.\-]+\.\w+$/;
|
||||
if (!emailRegex.test(email.trim())) {
|
||||
message.warning('请输入正确的邮箱格式');
|
||||
return;
|
||||
}
|
||||
const selectedOrders = Array.from(selectedOrdersMap.values());
|
||||
const totalAmount = selectedOrders.reduce((sum, o) => sum + (o.amount || o.total_amount || 0), 0);
|
||||
const now = new Date().toLocaleString('zh-CN');
|
||||
const selectedHeader = allHeadersForSelect.find(h => h.id === selectedHeaderId);
|
||||
const newRecord: InvoiceRecord = {
|
||||
id: Date.now().toString(),
|
||||
orderNo: generateRandomNo(),
|
||||
orderType: selectedOrders.length > 1 ? `${selectedOrders.length}笔订单合并` : (selectedOrders[0].type || '订单'),
|
||||
amount: totalAmount,
|
||||
createTime: now,
|
||||
status: 'pending',
|
||||
headerName: selectedHeader?.name,
|
||||
headerType: selectedHeader?.type,
|
||||
headerTaxNo: selectedHeader?.taxNo,
|
||||
selectedOrders,
|
||||
};
|
||||
setRecords(prev => [newRecord, ...prev]);
|
||||
setSelectHeaderModalOpen(false);
|
||||
setSelectedOrdersMap(new Map());
|
||||
setSelectedHeaderId(null);
|
||||
message.success(`开票申请已提交,合计金额 ¥${totalAmount.toFixed(2)}`);
|
||||
const orderIds = selectedOrders.map((o: any) => o.id || o.order_no || o.orderNo);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createInvoice({
|
||||
headerType: selectedHeader?.type as 'personal' | 'company',
|
||||
headerName: selectedHeader?.name || '',
|
||||
headerTaxNo: selectedHeader?.taxNo,
|
||||
headerRegisterAddress: selectedHeader?.registerAddress,
|
||||
headerRegisterPhone: selectedHeader?.registerPhone,
|
||||
headerBankName: selectedHeader?.bankName,
|
||||
headerBankAccount: selectedHeader?.bankAccount,
|
||||
email: email.trim(),
|
||||
orderIds,
|
||||
});
|
||||
message.success('开票申请已提交,请等待审核');
|
||||
setSelectHeaderModalOpen(false);
|
||||
setSelectedOrdersMap(new Map());
|
||||
setSelectedHeaderId(null);
|
||||
setEmail('');
|
||||
// 刷新发票列表
|
||||
loadInvoices();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || err?.response?.data?.detail || '提交失败,请稍后重试';
|
||||
message.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const orderRowSelection = {
|
||||
@@ -526,7 +577,9 @@ const InvoicePage: React.FC = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{records.length === 0 ? (
|
||||
{recordsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>加载中...</div>
|
||||
) : records.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<span style={{ color: '#94a3b8' }}>暂无申请开票记录</span>}
|
||||
@@ -603,6 +656,18 @@ const InvoicePage: React.FC = () => {
|
||||
currentDetail.status === 'issued' ? '已开具' : '已驳回'}
|
||||
</Tag>
|
||||
</span>
|
||||
{currentDetail.email && (
|
||||
<>
|
||||
<span style={{ color: '#94a3b8' }}>接收邮箱:</span>
|
||||
<span style={{ color: '#1e293b' }}>{currentDetail.email}</span>
|
||||
</>
|
||||
)}
|
||||
{currentDetail.failureReason && (
|
||||
<>
|
||||
<span style={{ color: '#94a3b8' }}>失败原因:</span>
|
||||
<span style={{ color: '#ef4444' }}>{currentDetail.failureReason}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -644,7 +709,7 @@ const InvoicePage: React.FC = () => {
|
||||
key: 'orderNo',
|
||||
render: (_: any, r: any) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12, color: '#64748b' }}>
|
||||
{r.order_no || r.orderNo || r.id}
|
||||
{r.order_no || r.orderNo || r.orderNo || r.id}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -746,13 +811,13 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
}
|
||||
open={selectHeaderModalOpen}
|
||||
onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); }}
|
||||
onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); setEmail(''); }}
|
||||
width={820}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); }}>
|
||||
<Button key="cancel" onClick={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); setEmail(''); }}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" onClick={handleHeaderConfirm}>
|
||||
<Button key="confirm" type="primary" loading={submitting} onClick={handleHeaderConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
]}
|
||||
@@ -761,6 +826,21 @@ const InvoicePage: React.FC = () => {
|
||||
closeIcon={<CloseOutlined style={{ fontSize: 16, color: '#94a3b8' }} />}
|
||||
>
|
||||
<div style={{ padding: '0 24px 24px' }}>
|
||||
{/* 邮箱输入 */}
|
||||
<div style={{ padding: '16px 0 12px', borderBottom: '1px solid #f1f5f9', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ color: '#94a3b8', fontSize: 13, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#ef4444', marginRight: 4 }}>*</span>接收邮箱:
|
||||
</span>
|
||||
<Input
|
||||
placeholder="发票将发送至该邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={allHeadersForSelect}
|
||||
columns={[
|
||||
|
||||
Reference in New Issue
Block a user