14 Commits
Author SHA1 Message Date
root e06a739cdd 1 2026-08-14 16:36:11 +08:00
root fe57416493 1 2026-08-14 16:19:07 +08:00
root c47fac821c 1 2026-08-14 16:16:30 +08:00
root a4417e8976 1 2026-08-14 16:11:07 +08:00
root 3194356859 1 2026-08-14 16:06:07 +08:00
root 978475f0cd 1 2026-08-14 15:59:47 +08:00
sjy 5be40ead52 对公转账信息 2026-08-14 15:55:41 +08:00
sjy 7fc58e62ce 修复对公转账 2026-08-14 15:54:21 +08:00
root 5eed7686c9 1 2026-08-14 15:52:52 +08:00
root 4a2355a902 1 2026-08-14 15:47:51 +08:00
root 7ab5c9a5c9 1 2026-08-14 15:42:12 +08:00
root 556ea4dbd2 1 2026-08-14 15:39:22 +08:00
sjy abb479c469 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:34:53 +08:00
sjy 4db879199c 保留修改 2026-08-14 13:29:00 +08:00
14 changed files with 626 additions and 328 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title> <title>后台管理</title>
<script> <script>
(function() { (function() {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
try { try {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteName) { if (info.siteName) {
document.title = info.siteName + ' - 管理后台'; document.title = info.siteName + ' - 管理后台';
} }
if (info.siteLogo) { if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]'); var link = document.querySelector('link[rel="icon"]');
if (link) { if (link) {
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
} }
} catch (e) {} } catch (e) {}
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CTeXmoRP.js"></script> <script type="module" crossorigin src="/assets/index-B0W8S7jH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css"> <link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd'; import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
import dayjs from 'dayjs';
import { BankOutlined, SearchOutlined } from '@ant-design/icons'; import { BankOutlined, SearchOutlined } from '@ant-design/icons';
import { queryBankTransactions, listBankAccounts } from '../api'; import { queryBankTransactions, listBankAccounts } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
@@ -8,19 +9,22 @@ import type { BankAccount } from '../types';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
interface TransactionRecord { interface TransactionRecord {
counterAcctNo?: string; id?: string;
cnterName?: string; accountId?: string;
cnterBankName?: string; accountNo?: string;
acctNo?: string; transactionNo?: string;
transAmt?: number | string; transactionTime?: string;
dcFlag?: number; transactionAmount?: string;
dcFlagLabel?: string; balanceDirection?: string;
transTimeStr?: string; balanceAfter?: string;
digestCode?: string; counterpartyName?: string;
counterpartyAccount?: string;
counterpartyBank?: string;
remark?: string; remark?: string;
balance?: number | string; digestCode?: string;
tellerSeqno?: string; syncBatch?: string;
transferId?: string; isSynced?: boolean;
createdAt?: string;
} }
const AdminBankTransactions: React.FC = () => { const AdminBankTransactions: React.FC = () => {
@@ -45,7 +49,18 @@ const AdminBankTransactions: React.FC = () => {
const loadAccounts = async () => { const loadAccounts = async () => {
try { try {
const res = await listBankAccounts(); const res = await listBankAccounts();
setAccounts(res.items || []); const items = res.items || [];
setAccounts(items);
// 默认选中默认账户(或第一个账户)
const defaultAccount = items.find(a => a.isDefault) || items[0];
if (defaultAccount) {
setSelectedAccountId(defaultAccount.id);
}
// 默认日期范围:今天
const today = new Date().toISOString().slice(0, 10);
setDateRange([today, today]);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '加载银行账户失败'); message.error(err?.message || '加载银行账户失败');
} }
@@ -95,72 +110,72 @@ const AdminBankTransactions: React.FC = () => {
const columns = [ const columns = [
{ {
title: '收款账号', title: '交易流水号',
dataIndex: 'counterAcctNo', dataIndex: 'transactionNo',
width: 180, width: 180,
render: (v: string) => v || '-', render: (v: string) => v || '-',
}, },
{ {
title: '收款户名', title: '交易时间',
dataIndex: 'cnterName', dataIndex: 'transactionTime',
width: 120,
render: (v: string) => v || '-',
},
{
title: '收款开户行',
dataIndex: 'cnterBankName',
width: 150,
render: (v: string) => v || '-',
},
{
title: '我方付款账号',
dataIndex: 'acctNo',
width: 180,
render: (v: string) => v || '-',
},
{
title: '打款金额',
dataIndex: 'transAmt',
width: 120,
align: 'right' as const,
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '借贷方向',
dataIndex: 'dcFlagLabel',
width: 100,
render: (v: string, r: TransactionRecord) => v || (r.dcFlag === 0 ? '借/出金' : r.dcFlag === 1 ? '贷/入金' : '-'),
},
{
title: '打款时间',
dataIndex: 'transTimeStr',
width: 160, width: 160,
render: (v: string) => v ? formatDate(v) : '-', render: (v: string) => v ? formatDate(v) : '-',
}, },
{ {
title: '摘要', title: '交易金额',
dataIndex: 'transactionAmount',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '借贷方向',
dataIndex: 'balanceDirection',
width: 100,
render: (v: string) => v === 'DR' ? '借/出金' : v === 'CR' ? '贷/入金' : '-',
},
{
title: '交易后余额',
dataIndex: 'balanceAfter',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '对方户名',
dataIndex: 'counterpartyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: '对方账号',
dataIndex: 'counterpartyAccount',
width: 180,
render: (v: string) => v || '-',
},
{
title: '对方开户行',
dataIndex: 'counterpartyBank',
width: 150,
render: (v: string) => v || '-',
},
{
title: '摘要码',
dataIndex: 'digestCode', dataIndex: 'digestCode',
width: 100, width: 100,
render: (v: string) => v || '-', render: (v: string) => v || '-',
}, },
{ {
title: '用途/备注', title: '备注',
dataIndex: 'remark', dataIndex: 'remark',
width: 150, width: 150,
render: (v: string) => v || '-', render: (v: string) => v || '-',
}, },
{ {
title: '账户余额', title: '同步时间',
dataIndex: 'balance', dataIndex: 'createdAt',
width: 120, width: 160,
align: 'right' as const, render: (v: string) => v ? formatDate(v) : '-',
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '流水号',
dataIndex: 'tellerSeqno',
width: 150,
render: (v: string) => v || '-',
}, },
]; ];
@@ -191,10 +206,11 @@ const AdminBankTransactions: React.FC = () => {
allowClear allowClear
options={accounts.map(a => ({ options={accounts.map(a => ({
value: a.id, value: a.id,
label: `${a.bankName} - ${a.accountNo} (${a.accountName})`, label: `${a.accountNo} - ${a.accountName}(${a.bankName})`,
}))} }))}
/> />
<RangePicker <RangePicker
value={dateRange[0] && dateRange[1] ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : undefined}
onChange={(dates, dateStrings) => { onChange={(dates, dateStrings) => {
if (Array.isArray(dateStrings)) { if (Array.isArray(dateStrings)) {
setDateRange([dateStrings[0] || null, dateStrings[1] || null]); setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
@@ -225,7 +241,7 @@ const AdminBankTransactions: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table <Table
rowKey={(r, i) => `${r.tellerSeqno || ''}-${r.transferId || ''}-${i}`} rowKey={(r) => r.id || `${r.transactionNo || ''}-${Math.random()}`}
columns={columns} columns={columns}
dataSource={data} dataSource={data}
loading={loading} loading={loading}
@@ -0,0 +1,27 @@
"""银行流水表加 created_at 字段
Revision ID: 20260814_bank_tx_ca
Revises: 20260814_bank_tx
Create Date: 2026-08-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, use by Alembic.
revision = '20260814_bank_tx_ca'
down_revision = 'e7f2527691bb'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'bank_transactions',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('ix_bank_transactions_created_at', 'bank_transactions', ['created_at'])
def downgrade():
op.drop_index('ix_bank_transactions_created_at', table_name='bank_transactions')
op.drop_column('bank_transactions', 'created_at')
+59 -10
View File
@@ -1,11 +1,15 @@
"""银行账户管理与交易查询后台路由。""" """银行账户管理与交易查询后台路由。"""
from datetime import datetime, time
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
import sqlalchemy as sa
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db from app.dependencies import get_admin_user, get_db
from app.models.bank_account import BankAccount from app.models.bank_account import BankAccount
from app.models.bank_transaction import BankTransaction
from app.models.user import User from app.models.user import User
from app.services.bank.service import query_transactions_with_log from app.services.bank.service import query_transactions_with_log
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -155,19 +159,64 @@ async def list_transactions(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""查询银行交易流水(带接口请求记录到文件日志)""" """查询本地已同步的银行交易流水。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id)) result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none() account = result.scalar_one_or_none()
if account is None: if account is None:
raise HTTPException(status_code=404, detail="银行账户不存在") raise HTTPException(status_code=404, detail="银行账户不存在")
data = await query_transactions_with_log( # 构建查询条件(日期字符串转为 datetime,避免 PostgreSQL 类型错误)
admin.id, conditions = [BankTransaction.account_id == account_id]
acct_no=account.account_no, if start_date:
start_date=start_date, start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_date=end_date, conditions.append(BankTransaction.transaction_time >= start_dt)
dc_flag=dc_flag, if end_date:
page=page, end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
page_size=page_size, hour=23, minute=59, second=59
)
conditions.append(BankTransaction.transaction_time <= end_dt)
if dc_flag is not None:
direction = "DR" if dc_flag == 0 else "CR"
conditions.append(BankTransaction.balance_direction == direction)
# 查总数
count_result = await db.execute(
select(sa.func.count()).where(*conditions)
) )
return data total = count_result.scalar() or 0
# 查分页数据
offset = (page - 1) * page_size
data_result = await db.execute(
select(BankTransaction)
.where(*conditions)
.order_by(BankTransaction.transaction_time.desc())
.offset(offset)
.limit(page_size)
)
transactions = data_result.scalars().all()
return {
"items": [
{
"id": t.id,
"account_id": t.account_id,
"account_no": t.account_no,
"transaction_no": t.transaction_no,
"transaction_time": t.transaction_time.isoformat() if t.transaction_time else None,
"transaction_amount": t.transaction_amount,
"balance_direction": t.balance_direction,
"balance_after": t.balance_after,
"counterparty_name": t.counterparty_name,
"counterparty_account": t.counterparty_account,
"counterparty_bank": t.counterparty_bank,
"remark": t.remark,
"digest_code": t.digest_code,
"sync_batch": t.sync_batch,
"is_synced": t.is_synced,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
],
"total": total,
}
@@ -148,9 +148,10 @@ async def run_task(
if task is None: if task is None:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
from app.tasks.celery_app import RECOVERY_QUEUE
from app.tasks.scheduled_tasks import execute_scheduled_task from app.tasks.scheduled_tasks import execute_scheduled_task
execute_scheduled_task.apply_async(args=[task_id]) execute_scheduled_task.apply_async(args=[task_id], queue=RECOVERY_QUEUE)
return {"message": "任务已提交执行"} return {"message": "任务已提交执行"}
@@ -1,5 +1,7 @@
"""银行交易流水模型。""" """银行交易流水模型。"""
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Index, String, Text from sqlalchemy import Boolean, DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -15,6 +17,14 @@ class BankTransaction(Base):
account_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False, comment="银行账户ID") account_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False, comment="银行账户ID")
account_no: Mapped[str] = mapped_column(String(64), index=True, nullable=False, comment="银行账号") account_no: Mapped[str] = mapped_column(String(64), index=True, nullable=False, comment="银行账号")
# 记录创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
comment="记录创建时间",
)
# 交易信息 # 交易信息
transaction_no: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True, comment="交易流水号(唯一)") transaction_no: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True, comment="交易流水号(唯一)")
transaction_time: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), index=True, nullable=True, comment="交易时间") transaction_time: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), index=True, nullable=True, comment="交易时间")
+11 -19
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger("video_gen")
_PAGE_SIZE = 100 _PAGE_SIZE = 100
def sync_bank_transactions( async def sync_bank_transactions(
url: str = "", url: str = "",
api_key: str = "", api_key: str = "",
acct_no: str = "", acct_no: str = "",
@@ -85,24 +85,16 @@ def sync_bank_transactions(
"errors": [], "errors": [],
} }
import asyncio await _do_sync(
loop = asyncio.new_event_loop() url=url,
asyncio.set_event_loop(loop) api_key=api_key,
try: acct_no=acct_no,
loop.run_until_complete( start_date=start_date,
_do_sync( end_date=end_date,
url=url, dc_flag=dc_flag,
api_key=api_key, sync_batch=sync_batch,
acct_no=acct_no, stats=stats,
start_date=start_date, )
end_date=end_date,
dc_flag=dc_flag,
sync_batch=sync_batch,
stats=stats,
)
)
finally:
loop.close()
logger.info( logger.info(
"银行流水同步完成: batch=%s, acct=%s, 翻页=%d, 获取=%d, 新增=%d, 重复=%d", "银行流水同步完成: batch=%s, acct=%s, 翻页=%d, 获取=%d, 新增=%d, 重复=%d",
+23 -22
View File
@@ -31,28 +31,28 @@ from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen") logger = logging.getLogger("video_gen")
def _update_task_status(task_id: str, status: str, error_msg: str | None = None) -> None: async def _update_task_status(task_id: str, status: str, error_msg: str | None = None) -> None:
"""更新任务最后执行状态。""" """更新任务最后执行状态。"""
async with async_session() as db:
async def _do(): result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
async with async_session() as db: task = result.scalar_one_or_none()
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) if task is None:
task = result.scalar_one_or_none() return
if task is None: task.last_run_at = datetime.now(timezone.utc).isoformat()
return task.last_status = status
task.last_run_at = datetime.now(timezone.utc).isoformat() task.last_error = error_msg
task.last_status = status await db.commit()
task.last_error = error_msg
await db.commit()
run_async(_do())
def _execute_internal_method(config: str | None) -> dict: async def _execute_internal_method(config: str | None) -> dict:
"""执行内部方法调用。 """执行内部方法调用。
配置 JSON 中 module 和 function 指定要调用的函数,其余字段作为 kwargs 传入。 配置 JSON 中 module 和 function 指定要调用的函数,其余字段作为 kwargs 传入。
支持同步函数和异步函数(async def)。
""" """
import importlib
from inspect import iscoroutinefunction
cfg = json.loads(config or "{}") cfg = json.loads(config or "{}")
module_path = cfg.pop("module", "").strip() module_path = cfg.pop("module", "").strip()
function_name = cfg.pop("function", "").strip() function_name = cfg.pop("function", "").strip()
@@ -60,8 +60,6 @@ def _execute_internal_method(config: str | None) -> dict:
if not module_path or not function_name: if not module_path or not function_name:
raise ValueError("内部方法需要指定 module 和 function") raise ValueError("内部方法需要指定 module 和 function")
import importlib
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
func = getattr(module, function_name, None) func = getattr(module, function_name, None)
if func is None or not callable(func): if func is None or not callable(func):
@@ -69,7 +67,11 @@ def _execute_internal_method(config: str | None) -> dict:
# 剩余字段作为 kwargs 传给函数 # 剩余字段作为 kwargs 传给函数
start = time.monotonic() start = time.monotonic()
result = func(**cfg) if iscoroutinefunction(func):
# 异步函数:直接 await
result = await func(**cfg)
else:
result = func(**cfg)
duration_ms = int((time.monotonic() - start) * 1000) duration_ms = int((time.monotonic() - start) * 1000)
return { return {
"duration_ms": duration_ms, "duration_ms": duration_ms,
@@ -91,16 +93,15 @@ def execute_scheduled_task(self, task_id: str):
if not task.is_active: if not task.is_active:
logger.info("定时任务已禁用,跳过执行: %s", task_id) logger.info("定时任务已禁用,跳过执行: %s", task_id)
return return
task_config = task.config task_config = task.config
try: try:
exec_result = _execute_internal_method(task_config) exec_result = await _execute_internal_method(task_config)
_update_task_status(task_id, "success") await _update_task_status(task_id, "success")
logger.info("定时任务执行成功: %s -> %s", task_id, exec_result) logger.info("定时任务执行成功: %s -> %s", task_id, exec_result)
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
_update_task_status(task_id, "error", error_msg) await _update_task_status(task_id, "error", error_msg)
logger.exception("定时任务执行失败: %s", task_id) logger.exception("定时任务执行失败: %s", task_id)
run_async(_run()) run_async(_run())
File diff suppressed because one or more lines are too long
+35 -35
View File
@@ -1,36 +1,36 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" /> <link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script> <script>
// 立即从 localStorage 设置 favicon,避免闪烁 // 立即从 localStorage 设置 favicon,避免闪烁
(function() { (function() {
try { try {
var cached = localStorage.getItem('siteInfo'); var cached = localStorage.getItem('siteInfo');
if (cached) { if (cached) {
var info = JSON.parse(cached); var info = JSON.parse(cached);
if (info.siteLogo) { if (info.siteLogo) {
var link = document.getElementById('favicon'); var link = document.getElementById('favicon');
link.href = info.siteLogo; link.href = info.siteLogo;
link.type = 'image/png'; link.type = 'image/png';
} }
if (info.siteName) { if (info.siteName) {
document.title = info.siteName; document.title = info.siteName;
} }
} }
} catch (e) {} } catch (e) {}
})(); })();
</script> </script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script type="module" crossorigin src="/assets/index-BMPNcnJK.js"></script> <script type="module" crossorigin src="/assets/index-Bc_ErQq7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css"> <link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+14
View File
@@ -574,6 +574,20 @@ export async function setDefaultInvoiceHeader(id: string): Promise<any> {
return api.put(`/invoice-headers/${id}/set-default`); return api.put(`/invoice-headers/${id}/set-default`);
} }
// ── Bank Accounts ──────────────────────────────────────────
export interface BankAccountInfo {
id: string;
accountName: string;
bankName: string;
accountNo: string;
description: string | null;
}
export async function getDefaultBankAccount(): Promise<{ hasAccount: boolean; account: BankAccountInfo | null }> {
return api.get('/bank/default-account');
}
export async function getCreditRatios(): Promise<any[]> { export async function getCreditRatios(): Promise<any[]> {
return api.get('/credits/ratios'); return api.get('/credits/ratios');
} }
+210 -24
View File
@@ -48,6 +48,7 @@ import {
CrownFilled, CrownFilled,
FireOutlined, FireOutlined,
BankFilled, BankFilled,
BankOutlined,
CheckCircleFilled, CheckCircleFilled,
WechatOutlined, WechatOutlined,
AlipayCircleOutlined, AlipayCircleOutlined,
@@ -60,7 +61,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore'; import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api'; import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername, getDefaultBankAccount } from '../../api';
import type { CreditProduct, CreditProductCatalog } from '../../types'; import type { CreditProduct, CreditProductCatalog } from '../../types';
import NotificationPopup from '../NotificationPopup'; import NotificationPopup from '../NotificationPopup';
import ActivityBanner from './ActivityBanner'; import ActivityBanner from './ActivityBanner';
@@ -397,7 +398,7 @@ const FAQ_ITEMS = [
label: '发票申请与联系方式', label: '发票申请与联系方式',
children: ( children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}> <div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: 0 }}> bd@liblib.ai"帮助中心"</p> <p style={{ margin: 0 }}>"帮助中心"</p>
</div> </div>
), ),
}, },
@@ -495,6 +496,24 @@ const AppLayout: React.FC = () => {
const [pendingProduct, setPendingProduct] = useState<{ id: string; product: CreditProduct; } | null>(null); const [pendingProduct, setPendingProduct] = useState<{ id: string; product: CreditProduct; } | null>(null);
const [purchaseQuantity, setPurchaseQuantity] = useState(1); const [purchaseQuantity, setPurchaseQuantity] = useState(1);
const [qrRevealed, setQrRevealed] = useState(false); const [qrRevealed, setQrRevealed] = useState(false);
const [paymentTab, setPaymentTab] = useState<'alipay' | 'corporate'>('alipay');
const [corporateTransferForm] = Form.useForm();
const [corporateSubmitting, setCorporateSubmitting] = useState(false);
const [bankAccountInfo, setBankAccountInfo] = useState<{ hasAccount: boolean; account: any } | null>(null);
const [bankAccountLoading, setBankAccountLoading] = useState(false);
const fetchBankAccount = useCallback(async () => {
if (bankAccountInfo || bankAccountLoading) return;
setBankAccountLoading(true);
try {
const data = await getDefaultBankAccount();
setBankAccountInfo(data);
} catch {
setBankAccountInfo({ hasAccount: false, account: null });
} finally {
setBankAccountLoading(false);
}
}, [bankAccountInfo, bankAccountLoading]);
const PENDING_ORDER_KEY = 'pending_payment_order'; const PENDING_ORDER_KEY = 'pending_payment_order';
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
@@ -886,6 +905,8 @@ const AppLayout: React.FC = () => {
setPendingProduct({ id: productId, product }); setPendingProduct({ id: productId, product });
setPurchaseQuantity(product.productType === 'team_subscription' ? 2 : 1); setPurchaseQuantity(product.productType === 'team_subscription' ? 2 : 1);
setQrRevealed(false); setQrRevealed(false);
setPaymentTab('alipay');
corporateTransferForm.resetFields();
setCurrentPaymentInfo(null); setCurrentPaymentInfo(null);
setCreditsModalOpen(false); setCreditsModalOpen(false);
setQrCodeModalOpen(true); setQrCodeModalOpen(true);
@@ -944,6 +965,50 @@ const AppLayout: React.FC = () => {
} }
}, [pendingProduct, paymentMethod, purchaseQuantity]); }, [pendingProduct, paymentMethod, purchaseQuantity]);
const submitCorporateTransfer = useCallback(async () => {
if (!pendingProduct) return;
try {
const values: any = await corporateTransferForm.validateFields();
setCorporateSubmitting(true);
const { id: productId, product } = pendingProduct;
const order = await createRechargeOrder(productId, 'corporate');
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
const originalPrice = product.regularPrice;
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
const isSubscription = !!product.billingCycle;
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
const tierName = product.name || '积分充值';
const displayTitle = isSubscription ? `${tierName} ${periodLabel}` : tierName;
const paymentInfo = {
price: finalPrice,
credits,
qrCode: '',
method: 'corporate',
title: displayTitle,
originalPrice: originalPrice ? Number(originalPrice) : undefined,
tierName,
periodLabel,
isSubscription,
};
void values;
setCurrentPaymentInfo(paymentInfo);
message.success('已提交对公转账信息,客服将在1-3个工作日内审核到账');
setQrCodeModalOpen(false);
setPendingProduct(null);
corporateTransferForm.resetFields();
await useAuthStore.getState().refreshUser();
await loadCreditCatalog();
} catch (err: any) {
if (err?.errorFields) {
return;
}
message.error(err?.message || '提交失败,请重试');
} finally {
setCorporateSubmitting(false);
}
}, [pendingProduct, corporateTransferForm]);
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => { const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
stopPolling(); stopPolling();
setCountdown(timeoutSeconds); setCountdown(timeoutSeconds);
@@ -1921,9 +1986,11 @@ const AppLayout: React.FC = () => {
setCurrentPaymentInfo(null); setCurrentPaymentInfo(null);
setPendingProduct(null); setPendingProduct(null);
setQrRevealed(false); setQrRevealed(false);
setPaymentTab('alipay');
corporateTransferForm.resetFields();
}} }}
footer={null} footer={null}
width={760} width={'50%'}
closable closable
title={ title={
<Typography.Title level={5} style={{ margin: 0 }}> <Typography.Title level={5} style={{ margin: 0 }}>
@@ -1998,31 +2065,150 @@ const AppLayout: React.FC = () => {
</div> </div>
</div> </div>
{/* 右侧:在线支付方式。后台线下成交不在客户端创建。 */} {/* 右侧:支付方式 */}
<div style={{ width: 300, padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}> <div style={{ width: '50%', padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}>
<Typography.Text strong style={{ marginBottom: 12 }}></Typography.Text> {/* Tab 切换 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 18 }}> <div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 10, padding: 4, marginBottom: 20 }}>
{enabledMethods.alipay && <Button type={paymentMethod === 'alipay' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('alipay'); }} icon={<AlipayCircleOutlined />}></Button>} <div
{enabledMethods.wechat && <Button type={paymentMethod === 'wechat' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('wechat'); }} icon={<WechatOutlined />}></Button>} onClick={() => setPaymentTab('alipay')}
style={{
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 600,
background: paymentTab === 'alipay' ? '#fff' : 'transparent',
color: paymentTab === 'alipay' ? '#1677ff' : '#64748b',
boxShadow: paymentTab === 'alipay' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<AlipayCircleOutlined style={{ color: paymentTab === 'alipay' ? '#1677ff' : '#94a3b8' }} />
</div>
<div
onClick={() => { setPaymentTab('corporate'); fetchBankAccount(); }}
style={{
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
cursor: 'pointer', fontSize: 14, fontWeight: 600,
background: paymentTab === 'corporate' ? '#fff' : 'transparent',
color: paymentTab === 'corporate' ? '#6366f1' : '#64748b',
boxShadow: paymentTab === 'corporate' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<BankOutlined style={{ color: paymentTab === 'corporate' ? '#6366f1' : '#94a3b8' }} />
</div>
</div> </div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ marginBottom: 12, color: '#64748b' }}>{paymentMethod === 'wechat' ? '请用微信扫码支付' : '请用支付宝扫码支付'}</div> {/* 支付宝 Tab */}
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}> {paymentTab === 'alipay' && (
{qrRevealed && currentPaymentInfo?.qrCode ? <QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" /> : ( <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 12 }}></div> <AlipayCircleOutlined style={{ fontSize: 18, color: '#1677ff' }} />
<Button type="primary" loading={paying} onClick={confirmPayment}></Button> <span style={{ fontSize: 14, color: '#64748b' }}></span>
</div>
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
{qrRevealed && currentPaymentInfo?.qrCode ? (
<QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" />
) : (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 12 }}><br/></div>
<Button type="primary" loading={paying} onClick={confirmPayment} style={{ borderRadius: 8, height: 36, fontWeight: 600 }}>
使
</Button>
</div>
)}
</div>
{qrRevealed && (
<div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>
{countdown}
</div> </div>
)} )}
{qrRevealed && (
<Button
danger
style={{ marginTop: 14, borderRadius: 8 }}
onClick={async () => {
stopPolling();
if (currentOrderNoRef.current) { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { } currentOrderNoRef.current = null; }
localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setPendingProduct(null); setQrRevealed(false); message.info('已取消支付');
}}
>
</Button>
)}
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
<br/>
</div>
</div> </div>
{qrRevealed && <div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>{countdown} </div>} )}
{qrRevealed && <Button danger style={{ marginTop: 14 }} onClick={async () => {
stopPolling(); {/* 对公转账 Tab */}
if (currentOrderNoRef.current) { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { } currentOrderNoRef.current = null; } {paymentTab === 'corporate' && (
localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setPendingProduct(null); setQrRevealed(false); message.info('已取消支付'); <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
}}></Button>} {/* 收款账户信息 */}
</div> <div style={{ marginBottom: 16 }}>
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>线/线</div> <Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 8, display: 'block', fontWeight: 600 }}></Typography.Text>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 8, border: '1px solid #e2e8f0', minHeight: 80, display: 'flex', alignItems: 'center' }}>
{bankAccountLoading ? (
<div style={{ width: '100%', textAlign: 'center', color: '#94a3b8', fontSize: 12 }}>...</div>
) : bankAccountInfo?.hasAccount && bankAccountInfo?.account ? (
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500 }}>{bankAccountInfo.account.accountName}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4 }}>
{/* <BankFilled style={{ color: '#c8161d', fontSize: 14 }} /> */}
{bankAccountInfo.account.bankName}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#1a1a2e', fontWeight: 500, fontFamily: 'monospace' }}>{bankAccountInfo.account.accountNo}</span>
</div>
</div>
) : (
<div style={{ width: '100%', textAlign: 'center', color: '#94a3b8', fontSize: 12 }}></div>
)}
</div>
</div>
{/* 打款信息填写 */}
<div style={{ marginBottom: 12 }}>
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 4, display: 'block', fontWeight: 600 }}></Typography.Text>
<Typography.Text style={{ fontSize: 11, color: '#ef4444', marginBottom: 8, display: 'block' }}></Typography.Text>
<Form form={corporateTransferForm} layout="vertical" size="small" requiredMark={false}>
<Form.Item name="accountName" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]} style={{ marginBottom: 10 }}>
<Input placeholder="请输入付款方账户名称" />
</Form.Item>
<Form.Item name="bankName" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]} style={{ marginBottom: 10 }}>
<Input placeholder="请输入开户银行" prefix={<BankOutlined style={{ color: '#94a3b8' }} />} />
</Form.Item>
<Form.Item name="bankAccount" label="账号" rules={[{ required: true, message: '请输入账号' }]} style={{ marginBottom: 8 }}>
<Input placeholder="请输入付款方账号" />
</Form.Item>
</Form>
</div>
<Button
type="primary"
block
size="large"
loading={corporateSubmitting}
onClick={submitCorporateTransfer}
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', fontWeight: 600, height: 38, fontSize: 14 }}
>
</Button>
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
12
</div>
</div>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -964,13 +964,15 @@ const GenerateConver: React.FC = () => {
{/* 上传视频 */} {/* 上传视频 */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b' }}>
<span style={{ color: '#f94444' }}></span>
<p style={{ fontSize: 12, color: '#64748b', fontWeight: 400, }}> <span style={{ color: '#f94444' }}></span>
</p>
<p style={{ fontSize: 12, color: '#64748b', fontWeight: 400, margin: '4px 0 0' }}>
{currentMaxDuration} {currentMaxDuration}
</p> </p>
</p> </div>
{videoUrl ? ( {videoUrl ? (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<video <video