This commit is contained in:
2026-08-10 16:29:26 +08:00
parent e364c46ce4
commit d1e8eb7316
18 changed files with 1335 additions and 819 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-222rrxOK.js"></script>
<script type="module" crossorigin src="/assets/index-D9cWJZKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
+4
View File
@@ -794,6 +794,8 @@ export async function getAdminGenerationRecords(params?: {
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}): Promise<{ total: number; items: any[] }> {
@@ -804,6 +806,8 @@ export async function getAdminGenerationRecords(params?: {
if (params?.includeMediaReferences !== undefined) {
q.set('include_media_references', String(params.includeMediaReferences));
}
if (params?.startDate) q.set('start_date', params.startDate);
if (params?.endDate) q.set('end_date', params.endDate);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString();
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -24,6 +25,7 @@ import {
VideoCameraOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -178,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationRecords: React.FC = () => {
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
const [total, setTotal] = useState(0);
@@ -195,6 +200,10 @@ const AdminGenerationRecords: React.FC = () => {
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
// 时间筛选(默认当天)
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -203,6 +212,8 @@ const AdminGenerationRecords: React.FC = () => {
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
page,
pageSize,
});
@@ -246,7 +257,7 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
useEffect(() => {
load();
@@ -300,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
}, [preview]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterUserId('');
setFilterEngineId('');
setFilterIncludeMedia('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1);
};
@@ -870,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
onPressEnter={handleSearch}
allowClear
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
+27 -13
View File
@@ -1,6 +1,7 @@
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 dayjs from 'dayjs';
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
import { formatDate } from '../utils/formatDate';
import type { InvoiceItem, InvoiceDetail } from '../types';
@@ -13,8 +14,8 @@ const AdminInvoices: React.FC = () => {
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 [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
// 详情弹窗
const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -35,8 +36,8 @@ const AdminInvoices: React.FC = () => {
pageSize,
status: statusFilter || undefined,
phone: phoneFilter || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
});
setData(res.items);
setTotal(res.total);
@@ -49,11 +50,20 @@ const AdminInvoices: React.FC = () => {
useEffect(() => {
fetchData();
}, [page, pageSize, statusFilter, startDate, endDate]);
}, [page, pageSize, statusFilter, queryCreatedRange]);
const handleSearch = () => {
setPage(1);
fetchData();
setQueryCreatedRange(createdRange);
};
const handleReset = () => {
setStatusFilter(null);
setPhoneFilter('');
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
};
const handleViewDetail = async (id: string) => {
@@ -267,15 +277,19 @@ const AdminInvoices: React.FC = () => {
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') : '')}
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</div>
{loading ? (
@@ -0,0 +1,65 @@
"""发票抬头表迁移
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
Revision ID: 20260811_20260811
Revises: 20260810_20260810
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260811_20260811'
down_revision: Union[str, None] = '20260810_20260810'
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:
op.create_table(
'invoice_headers',
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('type', sa.String(16), nullable=False),
sa.Column('name', sa.String(128), nullable=False),
sa.Column('tax_no', sa.String(32), nullable=True),
sa.Column('register_address', sa.String(256), nullable=True),
sa.Column('register_phone', sa.String(32), nullable=True),
sa.Column('bank_name', sa.String(128), nullable=True),
sa.Column('bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=True),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
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_headers_user', 'invoice_headers', ['user_id'])
# 表注释和字段注释
_comment_table('invoice_headers', '发票抬头表')
_comment_column('invoice_headers', 'id', '主键')
_comment_column('invoice_headers', 'user_id', '用户ID')
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
_comment_column('invoice_headers', 'name', '抬头名称')
_comment_column('invoice_headers', 'tax_no', '税号')
_comment_column('invoice_headers', 'register_address', '注册地址')
_comment_column('invoice_headers', 'register_phone', '注册电话')
_comment_column('invoice_headers', 'bank_name', '开户行')
_comment_column('invoice_headers', 'bank_account', '银行账号')
_comment_column('invoice_headers', 'email', '接收邮箱')
_comment_column('invoice_headers', 'is_default', '是否默认')
def downgrade() -> None:
op.drop_table('invoice_headers')
+2
View File
@@ -36,6 +36,7 @@ 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
from app.api.v1.invoice_headers import router as invoice_headers_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -74,3 +75,4 @@ 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)
api_router.include_router(invoice_headers_router)
+10
View File
@@ -2210,6 +2210,8 @@ async def admin_list_generation_records(
status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
@@ -2232,6 +2234,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
# Count total
count_query = (
@@ -2247,6 +2253,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
from app.services.invoice_header import (
create_header,
delete_header,
get_user_headers,
set_default_header,
update_header,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
def _header_to_out(header) -> dict:
return {
"id": header.id,
"userId": header.user_id,
"type": header.type,
"name": header.name,
"taxNo": header.tax_no,
"registerAddress": header.register_address,
"registerPhone": header.register_phone,
"bankName": header.bank_name,
"bankAccount": header.bank_account,
"email": header.email,
"isDefault": header.is_default,
"createdAt": header.created_at.isoformat() if header.created_at else None,
"updatedAt": header.updated_at.isoformat() if header.updated_at else None,
}
@router.post("", response_model=InvoiceHeaderOut)
async def create_invoice_header(
req: InvoiceHeaderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票抬头。"""
header = await create_header(db, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.get("")
async def list_invoice_headers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的所有发票抬头。"""
headers = await get_user_headers(db, current_user.id)
return {"items": [_header_to_out(h) for h in headers]}
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
async def update_invoice_header(
header_id: str,
req: InvoiceHeaderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票抬头。"""
header = await update_header(db, header_id, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.delete("/{header_id}")
async def delete_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除发票抬头。"""
await delete_header(db, header_id, current_user.id)
await db.commit()
return {"success": True}
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
async def set_default_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设置默认发票抬头。"""
header = await set_default_header(db, header_id, current_user.id)
await db.commit()
return _header_to_out(header)
+2 -1
View File
@@ -37,6 +37,7 @@ 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.invoice_header import InvoiceHeader
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -58,5 +59,5 @@ __all__ = [
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder",
"Invoice", "InvoiceOrder", "InvoiceHeader",
]
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
+52
View File
@@ -81,3 +81,55 @@ class InvoiceOut(BaseModel):
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
orders: list[InvoiceOrderOut] = []
# ── 发票抬头 ──────────────────────────────────────────────
class InvoiceHeaderCreate(BaseModel):
"""创建发票抬头请求。"""
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool = Field(False, description="是否设为默认")
@model_validator(mode="after")
def validate_company_fields(self) -> "InvoiceHeaderCreate":
if self.type == "company" and not self.tax_no:
raise ValueError("企业抬头必须填写税号")
return self
class InvoiceHeaderUpdate(BaseModel):
"""更新发票抬头请求。"""
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
tax_no: str | None = Field(None, max_length=32, description="税号")
register_address: str | None = Field(None, max_length=256, description="注册地址")
register_phone: str | None = Field(None, max_length=32, description="注册电话")
bank_name: str | None = Field(None, max_length=128, description="开户行")
bank_account: str | None = Field(None, max_length=64, description="银行账号")
email: str | None = Field(None, max_length=128, description="接收邮箱")
is_default: bool | None = Field(None, description="是否设为默认")
class InvoiceHeaderOut(BaseModel):
"""发票抬头响应体。"""
model_config = {"from_attributes": True}
id: str
user_id: str
type: str
name: str
tax_no: str | None = None
register_address: str | None = None
register_phone: str | None = None
bank_name: str | None = None
bank_account: str | None = None
email: str | None = None
is_default: bool = False
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
@@ -0,0 +1,128 @@
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invoice_header import InvoiceHeader
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderUpdate
from app.utils.id import generate_id
logger = logging.getLogger("videogen")
async def create_header(db: AsyncSession, user_id: str, data: InvoiceHeaderCreate) -> InvoiceHeader:
"""创建发票抬头。"""
now = datetime.now(timezone.utc)
header = InvoiceHeader(
id=generate_id(),
user_id=user_id,
type=data.type,
name=data.name,
tax_no=data.tax_no,
register_address=data.register_address,
register_phone=data.register_phone,
bank_name=data.bank_name,
bank_account=data.bank_account,
email=data.email,
is_default=data.is_default,
created_at=now,
updated_at=now,
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.values(is_default=False, updated_at=now)
)
db.add(header)
await db.flush()
return header
async def get_user_headers(db: AsyncSession, user_id: str) -> list[InvoiceHeader]:
"""获取用户的所有发票抬头。"""
result = await db.execute(
select(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id)
.order_by(InvoiceHeader.is_default.desc(), InvoiceHeader.created_at.desc())
)
return list(result.scalars().all())
async def get_header_by_id(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader | None:
"""获取指定发票抬头(仅限本人)。"""
result = await db.execute(
select(InvoiceHeader).where(
InvoiceHeader.id == header_id,
InvoiceHeader.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def update_header(
db: AsyncSession, header_id: str, user_id: str, data: InvoiceHeaderUpdate
) -> InvoiceHeader:
"""更新发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
update_data = {}
for field, value in data.model_dump(exclude_unset=True).items():
update_data[field] = value
if update_data:
update_data["updated_at"] = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.id == header_id)
.values(**update_data)
)
# 如果设为默认,先将其他抬头取消默认
if data.is_default:
now = datetime.now(timezone.utc)
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
await db.refresh(header)
return header
async def delete_header(db: AsyncSession, header_id: str, user_id: str) -> None:
"""删除发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
await db.delete(header)
await db.flush()
async def set_default_header(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader:
"""设置默认发票抬头。"""
header = await get_header_by_id(db, header_id, user_id)
if not header:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
now = datetime.now(timezone.utc)
# 先取消其他默认
await db.execute(
update(InvoiceHeader)
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
.values(is_default=False, updated_at=now)
)
# 设置当前为默认
header.is_default = True
header.updated_at = now
await db.flush()
return header
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script type="module" crossorigin src="/assets/index-Chp4mfc5.js"></script>
<script type="module" crossorigin src="/assets/index-D5aCA_p5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtYH0-uL.css">
</head>
<body>
+60
View File
@@ -497,6 +497,66 @@ export async function getInvoiceDetail(id: string): Promise<any> {
return api.get(`/invoices/${id}`);
}
// ── Invoice Headers ──────────────────────────────────────
export async function getInvoiceHeaders(): Promise<{ items: any[] }> {
return api.get('/invoice-headers');
}
export async function createInvoiceHeader(data: {
type: 'personal' | 'company';
name: string;
taxNo?: string;
registerAddress?: string;
registerPhone?: string;
bankName?: string;
bankAccount?: string;
email?: string;
isDefault?: boolean;
}): Promise<any> {
return api.post('/invoice-headers', {
type: data.type,
name: data.name,
tax_no: data.taxNo,
register_address: data.registerAddress,
register_phone: data.registerPhone,
bank_name: data.bankName,
bank_account: data.bankAccount,
email: data.email,
is_default: data.isDefault ?? false,
});
}
export async function updateInvoiceHeader(id: string, data: {
name?: string;
taxNo?: string;
registerAddress?: string;
registerPhone?: string;
bankName?: string;
bankAccount?: string;
email?: string;
isDefault?: boolean;
}): Promise<any> {
return api.put(`/invoice-headers/${id}`, {
name: data.name,
tax_no: data.taxNo,
register_address: data.registerAddress,
register_phone: data.registerPhone,
bank_name: data.bankName,
bank_account: data.bankAccount,
email: data.email,
is_default: data.isDefault,
});
}
export async function deleteInvoiceHeader(id: string): Promise<void> {
await api.delete(`/invoice-headers/${id}`);
}
export async function setDefaultInvoiceHeader(id: string): Promise<any> {
return api.put(`/invoice-headers/${id}/set-default`);
}
export async function getCreditRatios(): Promise<any[]> {
return api.get('/credits/ratios');
}
+63 -52
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Pagination, Button, Typography, Modal, Form, Input, Radio, message, Space, DatePicker } from 'antd';
import { FileTextOutlined, PlusOutlined, CloseOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
import { getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail } from '../api';
import dayjs from 'dayjs';
import {
getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail,
getInvoiceHeaders, createInvoiceHeader, updateInvoiceHeader, deleteInvoiceHeader, setDefaultInvoiceHeader,
} from '../api';
interface InvoiceRecord {
id: string;
@@ -33,13 +35,6 @@ interface InvoiceHeader {
isDefault: boolean;
}
const mockHeaders: InvoiceHeader[] = [
{ id: 'h1', name: '北京云图科技有限公司', type: 'company', taxNo: '91110108MA01XXXXX1', registerAddress: '北京市海淀区中关村大街1号', bankName: '中国工商银行北京海淀支行', registerPhone: '010-88888888', bankAccount: '0200 0000 0000 0001', isDefault: true },
{ id: 'h2', name: '上海智能图像有限公司', type: 'company', taxNo: '91310115MA1KXXXXX2', registerAddress: '上海市浦东新区张江高科技园区', bankName: '招商银行上海张江支行', registerPhone: '021-66666666', bankAccount: '1212 0000 0000 0002', isDefault: false },
{ id: 'h3', name: '深圳创新科技有限公司', type: 'company', taxNo: '91440300MA5XXXXX3', registerAddress: '深圳市南山区科技园', bankName: '建设银行深圳南山支行', registerPhone: '0755-99999999', bankAccount: '4400 0000 0000 0003', isDefault: false },
{ id: 'h4', name: '张三', type: 'personal', isDefault: false },
];
const InvoicePage: React.FC = () => {
const [records, setRecords] = useState<InvoiceRecord[]>([]);
const [recordsLoading, setRecordsLoading] = useState(false);
@@ -63,14 +58,9 @@ const InvoicePage: React.FC = () => {
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize] = useState(10);
const [occupiedOrderIds, setOccupiedOrderIds] = useState<Set<string>>(new Set());
// 默认日期范围:当前月份
const [orderDateFilter, setOrderDateFilter] = useState<[string | null, string | null]>(() => {
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const end = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return [start, end];
});
// 日期筛选:默认不选,展示全部订单
const [orderDateFilter, setOrderDateFilter] = useState<[string | null, string | null]>([null, null]);
const [orderDateRange, setOrderDateRange] = useState<any>([null, null]);
// 说明弹窗
const [noticeModalOpen, setNoticeModalOpen] = useState(false);
@@ -173,6 +163,7 @@ const InvoicePage: React.FC = () => {
useEffect(() => {
loadInvoices();
loadOccupiedOrderIds();
loadHeaders();
}, []);
useEffect(() => {
@@ -418,46 +409,57 @@ const InvoicePage: React.FC = () => {
},
];
// 选择弹窗中的抬头列表(合并 mockHeaders 和用户添加的)
const allHeadersForSelect = React.useMemo(() => {
const merged = [...mockHeaders];
headers.forEach(h => {
const idx = merged.findIndex(m => m.id === h.id);
if (idx >= 0) {
merged[idx] = h;
} else {
merged.push(h);
}
});
return merged;
}, [headers]);
// 选择弹窗中的抬头列表
const allHeadersForSelect = headers;
// 加载发票抬头列表
const loadHeaders = async () => {
try {
const data = await getInvoiceHeaders();
const items: InvoiceHeader[] = (data.items || []).map((h: any) => ({
id: h.id,
name: h.name,
type: h.type,
taxNo: h.taxNo,
registerAddress: h.registerAddress,
bankName: h.bankName,
registerPhone: h.registerPhone,
bankAccount: h.bankAccount,
email: h.email,
isDefault: h.isDefault,
}));
setHeaders(items);
} catch {
// 静默失败
}
};
const handleAddHeader = async () => {
try {
const values = await headerForm.validateFields();
const isCompany = values.type === 'company';
const newHeader: InvoiceHeader = {
id: Date.now().toString(),
name: values.name,
const isDefault = values.isDefault === 'yes' || (headers.length === 0 && !headers.some(h => h.isDefault));
await createInvoiceHeader({
type: values.type,
name: values.name,
taxNo: isCompany ? values.taxNo : undefined,
registerAddress: isCompany ? values.registerAddress : undefined,
bankName: isCompany ? values.bankName : undefined,
registerPhone: isCompany ? values.registerPhone : undefined,
bankAccount: isCompany ? values.bankAccount : undefined,
email: values.email,
isDefault: values.isDefault === 'yes' || (headers.length === 0 && !allHeadersForSelect.some(h => h.isDefault)),
};
if (newHeader.isDefault) {
setHeaders(prev => prev.map(h => ({ ...h, isDefault: false })));
}
setHeaders(prev => [...prev, newHeader]);
isDefault,
});
headerForm.resetFields();
setAddModalOpen(false);
message.success('发票抬头添加成功');
loadHeaders();
} catch (e: any) {
if (e?.errorFields) {
message.warning('请完善表单信息');
} else {
const msg = e?.message || e?.response?.data?.detail || '添加失败';
message.error(msg);
}
}
};
@@ -468,16 +470,26 @@ const InvoicePage: React.FC = () => {
content: '确定要删除该发票抬头吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
setHeaders(prev => prev.filter(h => h.id !== id));
message.success('删除成功');
onOk: async () => {
try {
await deleteInvoiceHeader(id);
message.success('删除成功');
loadHeaders();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
},
});
};
const handleSetDefault = (id: string) => {
setHeaders(prev => prev.map(h => ({ ...h, isDefault: h.id === id })));
message.success('已设为默认');
const handleSetDefault = async (id: string) => {
try {
await setDefaultInvoiceHeader(id);
message.success('已设为默认');
loadHeaders();
} catch (e: any) {
message.error(e?.message || '设置失败');
}
};
// 开票流程:选择订单后确认 → 打开抬头选择弹窗
@@ -838,23 +850,22 @@ const InvoicePage: React.FC = () => {
<span style={{ color: '#94a3b8', fontSize: 13 }}></span>
<DatePicker.RangePicker
size="small"
value={orderDateFilter[0] ? [dayjs(orderDateFilter[0]), orderDateFilter[1] ? dayjs(orderDateFilter[1]) : null] : [null, null]}
value={orderDateRange}
onChange={(dates) => {
setOrderDateRange(dates);
if (dates && dates[0] && dates[1]) {
const start = dates[0].format('YYYY-MM-DD');
const end = dates[1].format('YYYY-MM-DD');
setOrderDateFilter([start, end]);
} else {
// 清除时恢复默认当前月份
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const end = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
setOrderDateFilter([start, end]);
// 清除时展示全部
setOrderDateFilter([null, null]);
}
setOrderPage(1);
setTimeout(() => loadOrderData(1), 0);
}}
allowClear
placeholder={['开始日期', '结束日期']}
style={{ width: 280 }}
/>
</div>