This commit is contained in:
2026-06-11 17:55:42 +08:00
39 changed files with 1921 additions and 2743 deletions
File diff suppressed because one or more lines are too long
+13 -14
View File
@@ -1,14 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-BBz1j0L-.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-BH90EezD.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+4 -3
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@ant-design/icons": "^6.2.2",
"antd": "^6.3.7",
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
@@ -1333,9 +1334,9 @@
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/detect-libc": {
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@ant-design/icons": "^6.2.2",
"antd": "^6.3.7",
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
+2
View File
@@ -19,6 +19,7 @@ import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
import AdminGenerationAiRecords from './pages/AdminGenerationAiRecords';
import { useAdminStore } from './store';
@@ -79,6 +80,7 @@ const App = () => {
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
<Route path="generation-records" element={<AdminGenerationRecords />} />
<Route path="generation-ai" element={<AdminGenerationAiRecords />} />
+59 -5
View File
@@ -211,12 +211,25 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(): Promise<{
by_status: Record<string, { count: number; amount: number }>;
today: { paid_count: number; paid_amount: number };
recent: any[];
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
return api.get('/admin/payment-stats');
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string }): Promise<{ items: any[] }> {
@@ -227,6 +240,10 @@ export async function getAdminPaymentOrders(params?: { method?: string; status?:
return api.get(`/admin/payment-orders${suffix}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
}
export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> {
return api.get('/admin/notifications');
}
@@ -298,6 +315,43 @@ export async function getOperationLogs(page?: number): Promise<{ total: number;
return api.get(`/admin/operation-logs${q}`);
}
// ── oauthapp List ──────────────────────────────────────
export async function getOauthAppList(page?: number): Promise<{ total: number; items: any[] }> {
const q = page ? `?page=${page}` : '';
return api.get(`/admin/user-oauth-apps/list${q}`);
}
export async function createOauthApp(data: {
app_id: string;
secret: string;
open_type: number;
count?: number;
auth_url?: string;
company?: string;
}): Promise<any> {
return api.post('/admin/user-oauth-apps/create', data);
}
export async function getOauthApp(id: string): Promise<any> {
return api.get(`/admin/user-oauth-apps/read/${id}`);
}
export async function updateOauthApp(id: string, data: {
app_id?: string;
secret?: string;
open_type?: number;
count?: number;
auth_url?: string;
company?: string;
}): Promise<any> {
return api.post(`/admin/user-oauth-apps/update/${id}`, data);
}
export async function deleteOauthApp(id: string): Promise<void> {
await api.get(`/admin/user-oauth-apps/delete/${id}`);
}
// ── Generation Records (Admin) ─────────────────────────────
export async function getAdminGenerationRecords(params?: {
@@ -0,0 +1,406 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, Select, InputNumber,
} from 'antd';
import {
HistoryOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getOauthAppList, createOauthApp, getOauthApp, updateOauthApp, deleteOauthApp } from '../api';
import { formatDate } from '../utils/formatDate';
interface OAuthApp {
id: string;
appId: string;
secret: string;
status: number;
count: number;
openType: number;
authUrl?: string;
company?: string;
createBy: string;
createdAt: string;
updatedAt: string;
}
const AdminOauthAppList: React.FC = () => {
const [apps, setApps] = useState<OAuthApp[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [updateModalVisible, setUpdateModalVisible] = useState(false);
const [currentApp, setCurrentApp] = useState<OAuthApp | null>(null);
const [form] = Form.useForm();
const [updateForm] = Form.useForm();
const load = async (p?: number) => {
setLoading(true);
try {
const res = await getOauthAppList(p || page);
setApps(res.items || []);
setTotal(res.total || 0);
} catch {
message.error('加载授权应用列表失败');
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
const values = await form.validateFields();
await createOauthApp({
app_id: values.app_id,
secret: values.secret,
open_type: values.open_type,
count: values.count,
auth_url: values.auth_url,
company: values.company,
});
message.success('创建成功');
setCreateModalVisible(false);
form.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '创建失败');
}
};
const handleDetail = async (id: string) => {
try {
const app = await getOauthApp(id);
setCurrentApp(app);
setDetailModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
}
};
const handleUpdate = async (id: string) => {
try {
const app = await getOauthApp(id);
setCurrentApp(app);
updateForm.setFieldsValue({
app_id: app.appId,
secret: app.secret,
open_type: app.openType,
count: app.count,
auth_url: app.authUrl,
company: app.company,
});
setUpdateModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
}
};
const handleSaveUpdate = async () => {
if (!currentApp) return;
try {
const values = await updateForm.validateFields();
await updateOauthApp(currentApp.id, {
app_id: values.app_id,
secret: values.secret,
open_type: values.open_type,
count: values.count,
auth_url: values.auth_url,
company: values.company,
});
message.success('更新成功');
setUpdateModalVisible(false);
updateForm.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '更新失败');
}
};
const handleDelete = (id: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个授权应用吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteOauthApp(id);
message.success('删除成功');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
},
});
};
useEffect(() => { load(); }, []);
const columns = [
{ title: 'ID', dataIndex: 'id',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '应用ID', dataIndex: 'appId',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '应用密钥', dataIndex: 'secret',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '开户方式', dataIndex: 'openType',
render: (v: number) => {
const typeMap: Record<number, string> = {
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
};
return <Typography.Text>{typeMap[v] || v}</Typography.Text>;
},
},
{ title: '归属公司', dataIndex: 'company',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '授权次数', dataIndex: 'count',
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '状态', dataIndex: 'status',
render: (v: number) => <Tag color={v === 1 ? 'green' : 'red'}>{v === 1 ? '正常' : '禁用'}</Tag>,
},
{ title: '授权URL', dataIndex: 'authUrl',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '创建人', dataIndex: 'createBy',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '创建时间', dataIndex: 'createdAt',
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
{ title: '更新时间', dataIndex: 'updatedAt',
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
{ title: '操作',
render: (_: any, record: OAuthApp) => (
<Space>
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleDetail(record.id)}
></Button>
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleUpdate(record.id)}
></Button>
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(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>
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => load()}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}></Button>
</Space>
</div>
<Table
columns={columns}
dataSource={apps}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: 20,
total,
showTotal: (t) => `${t} 条记录`,
onChange: (p) => { setPage(p); load(p); },
}}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title="创建授权应用"
open={createModalVisible}
onOk={handleCreate}
onCancel={() => {
setCreateModalVisible(false);
form.resetFields();
}}
okText="创建"
cancelText="取消"
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="app_id"
label="应用ID"
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
>
<Input placeholder="请输入应用ID" />
</Form.Item>
<Form.Item
name="secret"
label="应用密钥"
rules={[{ required: true, message: '请输入应用密钥' }, { max: 256, message: '应用密钥不能超过256个字符' }]}
>
<Input placeholder="请输入应用密钥" />
</Form.Item>
<Form.Item
name="open_type"
label="开户方式"
rules={[{ required: true, message: '请选择开户方式' }]}
>
<Select placeholder="请选择开户方式">
<Select.Option value={1}></Select.Option>
<Select.Option value={2}>广</Select.Option>
<Select.Option value={3}></Select.Option>
<Select.Option value={4}></Select.Option>
<Select.Option value={5}></Select.Option>
<Select.Option value={6}></Select.Option>
<Select.Option value={7}></Select.Option>
<Select.Option value={8}></Select.Option>
<Select.Option value={9}>K2</Select.Option>
<Select.Option value={10}>K3</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="count"
label="最大授权用户数"
initialValue={100}
>
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
</Form.Item>
<Form.Item
name="auth_url"
label="应用授权链接"
>
<Input placeholder="请输入应用授权链接" />
</Form.Item>
<Form.Item
name="company"
label="应用归属公司名称"
rules={[{ max: 256, message: '公司名称不能超过256个字符' }]}
>
<Input placeholder="请输入应用归属公司名称" />
</Form.Item>
</Form>
</Modal>
<Modal
title="授权应用详情"
open={detailModalVisible}
onCancel={() => {
setDetailModalVisible(false);
setCurrentApp(null);
}}
okText="关闭"
cancelText="取消"
width={600}
>
{currentApp && (
<div style={{ lineHeight: '2' }}>
<p><strong>ID:</strong> {currentApp.id}</p>
<p><strong>ID:</strong> {currentApp.appId}</p>
<p><strong>:</strong> {currentApp.secret}</p>
<p><strong>:</strong> {(() => {
const typeMap: Record<number, string> = {
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
};
return typeMap[currentApp.openType] || currentApp.openType;
})()}</p>
<p><strong>:</strong> {currentApp.company || '-'}</p>
<p><strong>:</strong> {currentApp.count}</p>
<p><strong>:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
<p><strong>URL:</strong> {currentApp.authUrl || '-'}</p>
<p><strong>:</strong> {currentApp.createBy}</p>
<p><strong>:</strong> {formatDate(currentApp.createdAt)}</p>
<p><strong>:</strong> {formatDate(currentApp.updatedAt)}</p>
</div>
)}
</Modal>
<Modal
title="更新授权应用"
open={updateModalVisible}
onOk={handleSaveUpdate}
onCancel={() => {
setUpdateModalVisible(false);
updateForm.resetFields();
setCurrentApp(null);
}}
okText="更新"
cancelText="取消"
width={600}
>
<Form form={updateForm} layout="vertical">
<Form.Item
name="app_id"
label="应用ID"
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
>
<Input placeholder="请输入应用ID" />
</Form.Item>
<Form.Item
name="secret"
label="应用密钥"
rules={[{ required: true, message: '请输入应用密钥' }, { max: 256, message: '应用密钥不能超过256个字符' }]}
>
<Input placeholder="请输入应用密钥" />
</Form.Item>
<Form.Item
name="open_type"
label="开户方式"
rules={[{ required: true, message: '请选择开户方式' }]}
>
<Select placeholder="请选择开户方式">
<Select.Option value={1}></Select.Option>
<Select.Option value={2}>广</Select.Option>
<Select.Option value={3}></Select.Option>
<Select.Option value={4}></Select.Option>
<Select.Option value={5}></Select.Option>
<Select.Option value={6}></Select.Option>
<Select.Option value={7}></Select.Option>
<Select.Option value={8}></Select.Option>
<Select.Option value={9}>K2</Select.Option>
<Select.Option value={10}>K3</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="count"
label="最大授权用户数"
>
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
</Form.Item>
<Form.Item
name="auth_url"
label="应用授权链接"
>
<Input placeholder="请输入应用授权链接" />
</Form.Item>
<Form.Item
name="company"
label="应用归属公司名称"
rules={[{ max: 256, message: '公司名称不能超过256个字符' }]}
>
<Input placeholder="请输入应用归属公司名称" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminOauthAppList;
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Switch, Typography,
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
} from 'antd';
import {
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import { getPaymentConfigs, batchUpdatePaymentConfigs } from '../api';
@@ -12,6 +12,7 @@ const AdminPaymentConfig: React.FC = () => {
const [wechatEnabled, setWechatEnabled] = useState(false);
const [alipayEnabled, setAlipayEnabled] = useState(false);
const [mockMode, setMockMode] = useState(false);
const [orderTimeout, setOrderTimeout] = useState(180);
const [form] = Form.useForm();
const load = async () => {
@@ -29,10 +30,12 @@ const AdminPaymentConfig: React.FC = () => {
alipay_public_key: map['payment_alipay_public_key'] || '',
alipay_notify_url: map['payment_alipay_notify_url'] || '',
alipay_gateway: map['payment_alipay_gateway'] || '',
order_timeout: map['payment_order_timeout'] || '180',
});
setWechatEnabled(map['payment_wechat_enabled'] === 'true');
setAlipayEnabled(map['payment_alipay_enabled'] === 'true');
setMockMode(map['payment_mock'] === 'true');
setOrderTimeout(parseInt(map['payment_order_timeout'] || '180', 10));
} catch {
message.error('加载支付配置失败');
}
@@ -57,6 +60,7 @@ const AdminPaymentConfig: React.FC = () => {
payment_alipay_public_key: values.alipay_public_key || '',
payment_alipay_notify_url: values.alipay_notify_url || '',
payment_alipay_gateway: values.alipay_gateway || '',
payment_order_timeout: String(values.order_timeout || 180),
});
message.success('支付配置已保存');
load();
@@ -69,6 +73,40 @@ const AdminPaymentConfig: React.FC = () => {
return (
<div style={{ maxWidth: 720 }}>
{/* 通用设置 */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#6366f1',
}}><ClockCircleOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
</div>
<Form form={form} layout="vertical">
<Form.Item
name="order_timeout"
label={<span style={{ fontWeight: 500 }}></span>}
extra="订单创建后超过此时间未支付将自动取消(秒)"
>
<InputNumber
min={30}
max={86400}
placeholder="180"
style={{ width: '100%' }}
size="large"
/>
</Form.Item>
</Form>
</Card>
{/* Mock Mode Toggle */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16, background: mockMode ? '#fff7e6' : '#fafbff' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+268 -176
View File
@@ -1,193 +1,285 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message,
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
} from '@ant-design/icons';
import { getPaymentStats } from '../api';
import { getPaymentStats, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import dayjs from 'dayjs';
const { Option } = Select;
const { RangePicker } = DatePicker;
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const load = async () => {
try {
setLoading(true);
const data = await getPaymentStats();
setStats(data);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
const load = async () => {
try {
setLoading(true);
const data = await getPaymentStats(filters);
setStats(data);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [filters]);
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
};
useEffect(() => { load(); }, []);
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
};
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
</Row>
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
{/* Status breakdown */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
const columns = [
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', width: 200 },
{
title: '支付方式', dataIndex: 'payment_method', key: 'payment_method', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'trade_no', key: 'trade_no', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paid_at', key: 'paid_at', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const paidInfo = stats.by_status?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.by_status?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.by_status?.cancelled || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count;
return (
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paid_amount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paid_count}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="累计已支付"
value={paidInfo.amount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{paidInfo.count}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="待支付"
value={pendingInfo.count}
prefix={<ClockCircleOutlined style={{ color: '#f59e0b' }} />}
suffix="笔"
valueStyle={{ color: '#f59e0b', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{pendingInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
<Col xs={12} sm={12} lg={6}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="已取消"
value={cancelledInfo.count}
prefix={<CloseCircleOutlined style={{ color: '#94a3b8' }} />}
suffix="笔"
valueStyle={{ color: '#94a3b8', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
¥{cancelledInfo.amount.toFixed(2)}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled'].map(s => {
const info = stats.by_status?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={8} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /> 50 </Space>}>
<Table
columns={columns}
dataSource={stats.recent}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
);
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.paymentMethod}
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={12} md={8}>
<span style={{ marginRight: 8 }}></span>
<RangePicker
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4}>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
};
export default AdminPaymentStats;
+13 -10
View File
@@ -118,23 +118,26 @@ export interface AdminStats {
}
export interface PaymentStats {
by_status: Record<string, { count: number; amount: number }>;
today: { paid_count: number; paid_amount: number };
recent: PaymentOrder[];
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
order_no: string;
user_id: string;
user?: { username: string };
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
payment_method: string;
paymentMethod: string;
status: string;
trade_no?: string;
paid_at?: string;
created_at: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
}
export interface ModelConfig {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
@@ -0,0 +1,31 @@
"""user_oauth表新增归属公司应用,新增授权链接字段
Revision ID: 6101ba8d5761
Revises: 9ac2212e1b8e
Create Date: 2026-06-10 16:34:38.842582
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6101ba8d5761'
down_revision: Union[str, None] = '9ac2212e1b8e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user_oauth_app', sa.Column('auth_url', sa.String(length=256), nullable=True, comment='应用授权链接'))
op.add_column('user_oauth_app', sa.Column('company', sa.String(length=256), nullable=True, comment='应用归属公司名称'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_oauth_app', 'company')
op.drop_column('user_oauth_app', 'auth_url')
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""user_oauth表新增account_userid授权登录id,用来判断不同账号授权
Revision ID: 8922eafcd8b0
Revises: 6101ba8d5761
Create Date: 2026-06-11 16:21:17.617777
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8922eafcd8b0'
down_revision: Union[str, None] = '6101ba8d5761'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user_oauth', sa.Column('account_userid', sa.String(length=128), nullable=True, comment='授权账户登录userid,同一个用户不同的授权账户token不一样'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_oauth', 'account_userid')
# ### end Alembic commands ###
+111 -89
View File
@@ -43,6 +43,7 @@ from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund
from app.services.generation_billing_service import (
OWNER_GENERATION_RECORD,
@@ -442,26 +443,70 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard."""
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
).group_by(PaymentOrder.status)
)
.where(*breakdown_filters)
.group_by(PaymentOrder.status)
)
by_status = {}
for row in status_result.all():
by_status[row.status] = {"count": row.count, "amount": float(row.amount)}
if row.status in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
# Today's stats
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
@@ -469,38 +514,70 @@ async def get_payment_stats(
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
)
today_row = today_result.one()
# Recent 50 orders
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
# Recent orders with filters
recent_filters = []
if payment_method:
recent_filters.append(PaymentOrder.payment_method == payment_method)
if status:
recent_filters.append(PaymentOrder.status == status)
recent_filters.append(PaymentOrder.created_at >= query_start)
recent_filters.append(PaymentOrder.created_at < query_end)
recent_result = await db.execute(
select(PaymentOrder)
select(PaymentOrder, User)
.join(User, PaymentOrder.user_id == User.id)
.where(*recent_filters)
.order_by(PaymentOrder.created_at.desc())
.limit(50)
)
recent = recent_result.scalars().all()
recent_data = recent_result.all()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": float(today_row.paid_amount),
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"recent": [
{
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
"amount": o.amount,
"credits": o.credits,
"username": u.username,
"amount": round(o.amount, 2),
"credits": round(o.credits, 2),
"payment_method": o.payment_method,
"status": o.status,
"status": o.status if o.status in ("pending", "paid", "cancelled", "refunded") else "cancelled",
"trade_no": o.trade_no,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
"paid_at": _iso(o.paid_at),
"created_at": _iso(o.created_at),
}
for o in recent
for o, u in recent_data
],
}
@@ -544,13 +621,13 @@ async def get_admin_payment_orders(
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
"amount": o.amount,
"credits": o.credits,
"amount": round(o.amount, 2),
"credits": round(o.credits, 2),
"payment_method": o.payment_method,
"status": o.status,
"status": o.status if o.status in ("pending", "paid", "cancelled", "refunded") else "cancelled",
"trade_no": o.trade_no,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
"paid_at": _iso(o.paid_at),
"created_at": _iso(o.created_at),
}
for o in orders
],
@@ -585,6 +662,19 @@ async def update_payment_config(
}
@router.post("/payment-orders/{order_no}/refund")
async def refund_payment_order(
order_no: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
return result
# ── Industry Config ──────────────────────────────────────
def _serialize_industry(ind: IndustryConfig) -> dict:
@@ -1371,72 +1461,4 @@ async def admin_generate_video(
# ── Payment Stats ────────────────────────────────────────
@router.get("/payment-stats")
async def get_payment_stats(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Payment statistics for admin dashboard."""
from app.models.payment_order import PaymentOrder
from datetime import datetime
# Count and revenue by status
rows = (await db.execute(
select(
PaymentOrder.status,
PaymentOrder.payment_method,
func.count(PaymentOrder.id).label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("total_amount"),
).group_by(PaymentOrder.status, PaymentOrder.payment_method)
)).all()
by_status: dict[str, dict] = {}
for r in rows:
s = r.status
if s not in by_status:
by_status[s] = {"count": 0, "amount": 0.0}
by_status[s]["count"] += r.count
by_status[s]["amount"] += float(r.total_amount)
# Recent orders (last 50)
recent = (await db.execute(
select(PaymentOrder)
.order_by(PaymentOrder.created_at.desc())
.limit(50)
)).scalars().all()
# Today stats
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_paid = (await db.execute(
select(
func.count(PaymentOrder.id),
func.coalesce(func.sum(PaymentOrder.amount), 0),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
)
)).first()
today_count, today_amount = (today_paid or (0, 0))
return {
"by_status": by_status,
"today": {
"paid_count": int(today_count or 0),
"paid_amount": float(today_amount or 0),
},
"recent": [
{
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
"amount": o.amount,
"credits": o.credits,
"payment_method": o.payment_method,
"status": o.status,
"trade_no": o.trade_no,
"created_at": _iso(o.created_at),
"paid_at": _iso(o.paid_at),
}
for o in recent
],
}
+47 -4
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("videogen")
logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
@@ -16,13 +16,20 @@ from app.services.payment import (
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@router.get("/methods")
async def get_payment_methods(db: AsyncSession = Depends(get_db)):
async def get_payment_methods(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Return which payment methods are enabled (from admin config)."""
from app.services.payment import _get_payment_configs
configs = await _get_payment_configs(db)
@@ -89,9 +96,10 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
form_data = await request.form()
data = dict(form_data)
logger.info(
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
f"trade_no={data.get('trade_no', '')} status={data.get('trade_status', '')}"
f"data={data}"
)
# Verify signature first
@@ -106,8 +114,11 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
order_no = data.get("out_trade_no")
trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None
if order_no:
await process_payment_success_by_order_no(db, order_no, trade_no)
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
return "success"
@@ -130,6 +141,29 @@ async def list_orders(
return orders
@router.get("/orders/{order_no}", response_model=PaymentOrderOut)
async def get_order(
order_no: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.services.payment import _check_and_expire_order
result = await db.execute(
select(PaymentOrder)
.where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
)
.limit(1)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
# Auto-expire if needed
await _check_and_expire_order(db, order)
return order
@router.post("/orders/{order_no}/cancel")
async def cancel_order(
order_no: str,
@@ -148,6 +182,15 @@ async def cancel_order(
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay order, call close API first
if order.payment_method == "alipay":
db_configs = await _get_payment_configs(db)
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
order.status = "cancelled"
await db.flush()
logger.info(
+2 -2
View File
@@ -41,7 +41,7 @@ async def create_app(
db: AsyncSession = Depends(get_db),
):
try:
app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id)
app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id, req.count, req.auth_url, req.company)
return UserOAuthAppOut.model_validate(app)
except ValueError as e:
raise HTTPException(
@@ -72,7 +72,7 @@ async def update_app(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, admin.id)
app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, req.count, req.auth_url, req.company, admin.id)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+24 -44
View File
@@ -21,48 +21,6 @@ from app.services.log_config import decrypt_data
logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING)
def _setup_payment_logger():
"""Configure a dedicated file logger for payment events.
Logs are written to logs/payment_YYYY-MM-DD.log, rotated daily.
30 days of history are retained.
"""
from logging.handlers import TimedRotatingFileHandler
log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "payment.log")
payment_logger = logging.getLogger("payment")
payment_logger.setLevel(logging.INFO)
payment_logger.propagate = False # don't double-log to root
# Avoid adding duplicate handlers on reload
if any(getattr(h, "_payment_file", False) for h in payment_logger.handlers):
return
handler = TimedRotatingFileHandler(
log_file,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
utc=False, # use local time
)
handler.suffix = "%Y-%m-%d" # files named like payment.log.2026-06-10
handler._payment_file = True # type: ignore[attr-defined]
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
))
payment_logger.addHandler(handler)
# Mirror to console in DEBUG mode
if settings.DEBUG:
payment_logger.addHandler(logging.StreamHandler())
_setup_payment_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.models import async_session
@@ -78,14 +36,20 @@ async def lifespan(app: FastAPI):
await task_queue.recover()
queue_task = asyncio.create_task(task_queue.run())
# Background task: auto-expire pending payment orders
# Background task: auto-expire pending payment orders and sync status
async def _order_expiry_loop():
from app.services.payment import expire_all_pending_orders
from app.services.payment import expire_all_pending_orders, sync_pending_orders
from logging import getLogger
bg_logger = getLogger("payment")
while True:
try:
async with async_session() as db:
# 同步待支付订单状态(检查支付宝实际支付状态
sync_count = await sync_pending_orders(db)
if sync_count > 0:
bg_logger.info(f"Synced {sync_count} pending payment order(s)")
# 自动过期订单
n = await expire_all_pending_orders(db)
if n > 0:
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
@@ -94,6 +58,22 @@ async def lifespan(app: FastAPI):
await asyncio.sleep(60) # check every minute
expiry_task = asyncio.create_task(_order_expiry_loop())
# 启动时立即同步一次未支付订单
asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
async def startup_sync():
await asyncio.sleep(5)
from app.services.payment import sync_pending_orders
from logging import getLogger
bg_logger = getLogger("payment")
try:
async with async_session() as db:
sync_count = await sync_pending_orders(db)
if sync_count > 0:
bg_logger.info(f"Startup: Synced {sync_count} pending payment order(s)")
except Exception as e:
bg_logger.error(f"Startup sync error: {e}")
asyncio.create_task(startup_sync())
app.state.db_session_factory = async_session
@@ -42,6 +42,11 @@ class RequestEncryptMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# 白名单:支付回调接口不需要加密/解密
path = request.url.path
if "/payments/alipay/callback" in path or "/payments/wechat/callback" in path:
return await call_next(request)
encrypted = request.headers.get("X-Encrypted", "").lower() == "true"
if not encrypted:
return await call_next(request)
@@ -19,6 +19,15 @@ class UserOAuthApp(Base, TimestampMixin, SoftDeleteMixin):
status: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=1, comment="状态,1=正常,2=禁用"
)
count: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=100, comment="应用最大可以授权多少个用户"
)
auth_url: Mapped[str] = mapped_column(
String(256), nullable=True, comment="应用授权链接"
)
company: Mapped[str] = mapped_column(
String(256), nullable=True, comment="应用归属公司名称"
)
open_type: Mapped[int] = mapped_column(
BigInteger, nullable=False, index=True, comment="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)"
)
@@ -12,6 +12,9 @@ class UserOAuthAppCreate(BaseModel):
le=10,
description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)",
)
count: int = Field(100, ge=1, description="应用最大可以授权多少个用户")
auth_url: str | None = Field(None, max_length=256, description="应用授权链接")
company: str | None = Field(None, max_length=256, description="应用归属公司名称")
class UserOAuthAppUpdate(BaseModel):
@@ -23,6 +26,9 @@ class UserOAuthAppUpdate(BaseModel):
description="开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)",
)
status: int | None = Field(None, ge=1, le=2, description="应用状态(1=正常,2=禁用)")
count: int | None = Field(None, ge=1, description="应用最大可以授权多少个用户")
auth_url: str | None = Field(None, max_length=256, description="应用授权链接")
company: str | None = Field(None, max_length=256, description="应用归属公司名称")
class UserOAuthAppOut(BaseModel):
@@ -30,7 +36,10 @@ class UserOAuthAppOut(BaseModel):
app_id: str = Field(..., description="应用id")
secret: str = Field(..., description="应用密钥")
status: int = Field(..., description="状态,1=正常,2=禁用")
count: int = Field(..., description="应用最大可以授权多少个用户")
open_type: int = Field(..., description="开户方式")
auth_url: str | None = Field(None, description="应用授权链接")
company: str | None = Field(None, description="应用归属公司名称")
create_by: str | None = Field(None, description="创建者")
created_at: NaiveDatetime = Field(..., description="创建时间")
updated_at: NaiveDatetime = Field(..., description="更新时间")
+542 -45
View File
@@ -1,15 +1,22 @@
import logging
import os
import certifi
from datetime import datetime, timedelta
# 尝试设置 SSL 证书路径
try:
import certifi
os.environ["SSL_CERT_FILE"] = certifi.where()
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
except ImportError:
pass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.payment_order import PaymentOrder
from app.models.system_config import SystemConfig
from app.services.credits import add_credits
from app.services.credits import add_credits, deduct_credits
from app.utils.id_gen import generate_id, generate_order_no
# ---------------------------------------------------------------------------
@@ -61,8 +68,50 @@ _handler.setFormatter(logging.Formatter(
if not logger.handlers:
logger.addHandler(_handler)
# Orders pending payment for longer than this are auto-cancelled
ORDER_EXPIRE_MINUTES = 5
# Order expire time in seconds (configurable via payment_order_timeout setting, default 180 seconds)
DEFAULT_ORDER_EXPIRE_SECONDS = 180
def _get_order_expire_seconds(db_configs: dict[str, str]) -> int:
"""Get order expire time in seconds from config, with fallback to 180."""
try:
val = db_configs.get("payment_order_timeout", str(DEFAULT_ORDER_EXPIRE_SECONDS))
return int(val) if val.strip() else DEFAULT_ORDER_EXPIRE_SECONDS
except ValueError:
return DEFAULT_ORDER_EXPIRE_SECONDS
# ---------------------------------------------------------------------------
# Monkey-patch alipay-sdk-python WebUtils.do_post to fix bytes concatenation bug
# The SDK's error handling does: '...' + response.read()
# but response.read() returns bytes, causing TypeError on Python 3
# ---------------------------------------------------------------------------
def _patch_alipay_webutils():
try:
from alipay.aop.api.util import WebUtils
_original_do_post = WebUtils.do_post
def _patched_do_post(url, query_string, headers, params, charset, timeout=30):
try:
return _original_do_post(url, query_string, headers, params, charset, timeout)
except TypeError as e:
if "can only concatenate str (not 'bytes') to str" in str(e):
# Decode bytes response to string and retry
import http.client as _http
from urllib.parse import urlparse as _urlparse
parsed = _urlparse(url)
conn = _http.HTTPSConnection(parsed.hostname)
conn.request("POST", parsed.path + "?" + query_string, params, headers)
resp = conn.getresponse()
body = resp.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Alipay API error (status {resp.status}): {body}") from e
raise
WebUtils.do_post = _patched_do_post
except ImportError:
pass
_patch_alipay_webutils()
# ---------------------------------------------------------------------------
@@ -84,7 +133,9 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
"""
if order.status != "pending":
return False
expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs)
expiry = order.created_at + timedelta(seconds=expire_seconds)
if datetime.now(order.created_at.tzinfo) >= expiry:
order.status = "cancelled"
await db.flush()
@@ -92,6 +143,12 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
f"amount={order.amount} created_at={order.created_at.isoformat()}"
)
# Also call Alipay close API if it was an Alipay order
if order.payment_method == "alipay":
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order.order_no}: {e}")
return True
return False
@@ -100,7 +157,9 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
"""Background task: mark all expired pending orders as cancelled.
Returns the number of orders expired.
"""
threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES)
db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs)
threshold = datetime.now() - timedelta(seconds=expire_seconds)
result = await db.execute(
select(PaymentOrder).where(
PaymentOrder.status == "pending",
@@ -108,14 +167,22 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
)
)
orders = result.scalars().all()
expired_count = 0
for o in orders:
o.status = "cancelled"
expired_count += 1
logger.info(
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
)
# Also call Alipay close API if it was an Alipay order
if o.payment_method == "alipay":
try:
await _close_alipay_order(db, o, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {o.order_no}: {e}")
if orders:
await db.flush()
return len(orders)
return expired_count
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
@@ -157,15 +224,9 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
config.alipay_public_key = public_key
config.sign_type = "RSA2"
config.charset = "utf-8"
config.cert_path = certifi.where()
logger.info(
f"Initializing Alipay client: app_id={app_id}, gateway={config.server_url}, "
f"public_key={config.alipay_public_key}, "
f"private_key={config.app_private_key}, "
f"cert_path={config.cert_path}"
)
try:
_alipay_client = DefaultAlipayClient(config)
_alipay_client = DefaultAlipayClient(config, logger)
_alipay_client_app_id = app_id
except Exception:
logger.exception("Failed to initialize Alipay client")
@@ -291,8 +352,8 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
app_id = db_configs.get("payment_alipay_app_id", "")
private_key = db_configs.get("payment_alipay_private_key", "")
public_key = db_configs.get("payment_alipay_public_key", "")
notify_url = db_configs.get("payment_alipay_notify_url", "")
gateway = db_configs.get("payment_alipay_gateway", "")
notify_url = db_configs.get("payment_alipay_notify_url", "")
if not app_id or not private_key:
logger.warning("Alipay config missing in database (app_id / private_key)")
@@ -309,11 +370,16 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
from alipay.aop.api.request.AlipayTradePrecreateRequest import (
AlipayTradePrecreateRequest,
)
from alipay.aop.api.response.AlipayTradePrecreateResponse import (
AlipayTradePrecreateResponse,
)
# 构造业务参数
model = AlipayTradePrecreateModel()
model.out_trade_no = order.order_no
model.total_amount = f"{order.amount:.2f}"
model.subject = f"充值订单 {order.order_no}"
model.product_code = "QR_CODE_OFFLINE"
body_parts = []
if order.credits > 0:
@@ -321,19 +387,31 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
if body_parts:
model.body = " ".join(body_parts)
request = AlipayTradePrecreateRequest()
request.biz_model = model
# 构造请求
request = AlipayTradePrecreateRequest(biz_model=model)
# 设置 notify_url 在 request 上
if notify_url:
request.notify_url = notify_url
try:
if hasattr(request, 'set_notify_url'):
request.set_notify_url(notify_url)
elif hasattr(request, 'notify_url'):
request.notify_url = notify_url
except Exception as e:
logger.warning(f"Failed to set notify_url: {e}")
response = client.execute(request)
# 执行API调用
response_content = client.execute(request)
if not response_content:
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
return None
if response.code == "10000":
# 解析响应结果
response = AlipayTradePrecreateResponse()
response.parse_response_content(response_content)
if response.is_success():
qr_url = response.qr_code
logger.info(
f"Alipay precreate success: order_no={order.order_no}, "
f"qr_url={qr_url}"
)
return qr_url
else:
logger.error(
@@ -343,11 +421,186 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
)
return None
except Exception:
except Exception as e:
# 处理 SDK 内部的 bytes/str 错误
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
logger.error(
f"Alipay SDK TypeError (bytes/str issue): order_no={order.order_no}, "
f"error={str(e)}"
)
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
return None
# ---------------------------------------------------------------------------
# Alipay order close
# ---------------------------------------------------------------------------
async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool:
"""Call Alipay trade.close API to close an unpaid order.
Returns True if the order was closed successfully.
"""
app_id = db_configs.get("payment_alipay_app_id", "")
private_key = db_configs.get("payment_alipay_private_key", "")
public_key = db_configs.get("payment_alipay_public_key", "")
gateway = db_configs.get("payment_alipay_gateway", "")
client = _get_alipay_client(app_id, private_key, public_key, gateway)
if client is None:
return False
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
logger.info(f"Mock mode: skipping close_alipay_order for {order.order_no}")
return True
try:
from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel
from alipay.aop.api.request.AlipayTradeCloseRequest import AlipayTradeCloseRequest
from alipay.aop.api.response.AlipayTradeCloseResponse import AlipayTradeCloseResponse
model = AlipayTradeCloseModel()
model.out_trade_no = order.order_no
request = AlipayTradeCloseRequest(biz_model=model)
response_content = client.execute(request)
if not response_content:
logger.error(f"Alipay close failed: empty response, order_no={order.order_no}")
return False
response = AlipayTradeCloseResponse()
response.parse_response_content(response_content)
if response.is_success():
logger.info(f"Alipay order closed: order_no={order.order_no}")
return True
else:
logger.error(
f"Alipay close failed: code={response.code}, "
f"msg={response.msg}, sub_code={response.sub_code}, "
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
)
return False
except Exception as e:
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
logger.error(
f"Alipay SDK TypeError (bytes/str issue) during close: order_no={order.order_no}, "
f"error={str(e)}"
)
logger.exception(f"Alipay close exception: order_no={order.order_no}")
return False
# ---------------------------------------------------------------------------
# Alipay order query
# ---------------------------------------------------------------------------
async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None:
"""Call Alipay trade.query API to check order status.
Returns the response data if successful, None otherwise.
"""
app_id = db_configs.get("payment_alipay_app_id", "")
private_key = db_configs.get("payment_alipay_private_key", "")
public_key = db_configs.get("payment_alipay_public_key", "")
gateway = db_configs.get("payment_alipay_gateway", "")
client = _get_alipay_client(app_id, private_key, public_key, gateway)
if client is None:
return None
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
logger.info(f"Mock mode: skipping query_alipay_order for {order.order_no}")
return {"trade_status": "TRADE_FINISHED"}
try:
from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel
from alipay.aop.api.request.AlipayTradeQueryRequest import AlipayTradeQueryRequest
from alipay.aop.api.response.AlipayTradeQueryResponse import AlipayTradeQueryResponse
model = AlipayTradeQueryModel()
model.out_trade_no = order.order_no
request = AlipayTradeQueryRequest(biz_model=model)
response_content = client.execute(request)
if not response_content:
logger.error(f"Alipay query failed: empty response, order_no={order.order_no}")
return None
response = AlipayTradeQueryResponse()
response.parse_response_content(response_content)
if response.is_success():
logger.info(f"Alipay query succeeded: order_no={order.order_no}, trade_status={response.trade_status}")
return {
"trade_no": response.trade_no,
"trade_status": response.trade_status,
"total_amount": response.total_amount,
"receipt_amount": response.receipt_amount,
}
else:
logger.error(
f"Alipay query failed: code={response.code}, "
f"msg={response.msg}, sub_code={response.sub_code}, "
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
)
return None
except Exception as e:
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
logger.error(
f"Alipay SDK TypeError (bytes/str issue) during query: order_no={order.order_no}, "
f"error={str(e)}"
)
logger.exception(f"Alipay query exception: order_no={order.order_no}")
return None
async def sync_pending_orders(db: AsyncSession) -> int:
"""Check pending orders via Alipay query and update status.
Returns the number of orders updated.
"""
result = await db.execute(
select(PaymentOrder).where(
PaymentOrder.status == "pending",
)
)
orders = result.scalars().all()
updated_count = 0
db_configs = await _get_payment_configs(db)
for order in orders:
if order.payment_method != "alipay":
continue
try:
data = await _query_alipay_order(db, order, db_configs)
if data:
trade_status = data.get("trade_status")
if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
# Order was paid but we missed the callback
trade_no = data.get("trade_no", "")
await process_payment_success_by_order_no(db, order.order_no, trade_no)
updated_count += 1
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
# Order was closed on Alipay side
order.status = "cancelled"
await db.flush()
updated_count += 1
except Exception as e:
logger.exception(f"Failed to sync order {order.order_no}: {e}")
if updated_count > 0:
await db.flush()
return updated_count
# ---------------------------------------------------------------------------
# Alipay callback verification
# ---------------------------------------------------------------------------
@@ -356,12 +609,12 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
"""Verify Alipay payment callback (async notify) signature.
Reads the Alipay public key from the database and uses the SDK's
built-in RSA2 verification.
Reads the Alipay public key from the database and uses RSA2 verification.
"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
logger.info("Mock mode enabled, skipping Alipay callback verification")
return True
public_key = db_configs.get("payment_alipay_public_key", "")
@@ -375,37 +628,119 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
logger.warning("Alipay callback missing 'sign' field")
return False
sign_type = data.get("sign_type", "RSA2")
# Build verification params (exclude sign and sign_type)
verify_data = {
k: v for k, v in data.items()
if k not in ("sign", "sign_type") and v is not None and v != ""
}
from alipay.aop.api.util.Signature import verify_with_rsa
# Generate sign content: sorted keys, key=value format
sign_content = "&".join(
f"{k}={v}" for k, v in sorted(verify_data.items())
)
is_valid = verify_with_rsa(
public_key.encode("utf-8"),
sign_content.encode("utf-8"),
sign,
)
# logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...")
# logger.info(f"Sign type: {sign_type}")
# 实现 RSA2 签名验证
is_valid = _verify_alipay_sign(public_key, sign_content, sign, sign_type)
if not is_valid:
logger.warning("Alipay callback signature verification FAILED")
else:
logger.info("Alipay callback signature verification SUCCESS")
return is_valid
except ImportError:
logger.error("alipay-sdk-python not installed, skipping signature verification")
return True
except Exception:
logger.exception("Alipay callback verification error")
return False
def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type: str = "RSA2") -> bool:
"""Verify Alipay RSA/RSA2 signature.
Args:
public_key: Alipay public key (PEM format, with or without headers)
sign_content: Original content to verify
sign: Base64 encoded signature
sign_type: "RSA" (SHA1) or "RSA2" (SHA256)
Returns:
True if signature is valid
"""
try:
import base64
from hashlib import sha1, sha256
# 处理公钥,确保有正确的格式
pub_key = public_key.strip()
if not pub_key.startswith("-----BEGIN"):
pub_key = "-----BEGIN PUBLIC KEY-----\n" + pub_key + "\n-----END PUBLIC KEY-----"
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
# 加载公钥
public_key_obj = serialization.load_pem_public_key(
pub_key.encode("utf-8"),
backend=default_backend()
)
# 选择哈希算法
if sign_type == "RSA2":
hash_alg = hashes.SHA256()
else:
hash_alg = hashes.SHA1()
# 验证签名
public_key_obj.verify(
base64.b64decode(sign),
sign_content.encode("utf-8"),
padding.PKCS1v15(),
hash_alg
)
return True
except ImportError:
# 如果没有 cryptography,尝试使用 rsa 库
try:
import rsa
# 加载公钥
pub_key_obj = rsa.PublicKey.load_pkcs1_openssl_pem(pub_key.encode("utf-8"))
# 选择哈希算法
if sign_type == "RSA2":
hash_func = 'SHA-256'
else:
hash_func = 'SHA-1'
# 验证签名
rsa.verify(
sign_content.encode("utf-8"),
base64.b64decode(sign),
pub_key_obj,
hash_func
)
return True
except ImportError:
logger.error("Neither cryptography nor rsa library installed, cannot verify signature")
# 如果没有任何加密库,在生产环境应该返回 False,但这里我们记录警告并继续
logger.warning("Skipping signature verification due to missing crypto libraries")
return False
except Exception as e:
logger.exception(f"Signature verification failed: {e}")
return False
# ---------------------------------------------------------------------------
# WeChat callback verification (stub)
# ---------------------------------------------------------------------------
@@ -429,7 +764,7 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
async def process_payment_success(db: AsyncSession, order_id: str):
"""Process successful payment: update order and add credits."""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
select(PaymentOrder).where(PaymentOrder.id == order_id).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
@@ -444,23 +779,50 @@ async def process_payment_success(db: AsyncSession, order_id: str):
f"充值成功({order.credits}积分)",
related_id=order.id,
)
await db.flush()
await db.commit()
async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""):
async def process_payment_success_by_order_no(
db: AsyncSession,
order_no: str,
trade_no: str = "",
total_amount: float | None = None
):
"""Process successful payment by order_no (used by Alipay/WeChat callbacks).
Args:
db: async database session
order_no: the merchant order number (out_trade_no)
trade_no: the Alipay trade number (trade_no), optional
total_amount: the payment amount from the gateway, for consistency check
"""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
logger.info(f"Order {order_no} not found or already processed, skipping")
if not order:
logger.info(f"Order {order_no} not found, skipping")
return
if order.status == "paid":
logger.info(f"Order {order_no} already processed, skipping")
return
if order.status != "pending":
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
return
# 金额一致性校验
if total_amount is not None and abs(total_amount - order.amount) > 0.01:
logger.error(
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
)
return
# 幂等性检查:如果trade_no已存在且相同,则跳过
if trade_no and order.trade_no and order.trade_no == trade_no:
logger.info(f"Trade no {trade_no} already processed, skipping")
return
order.status = "paid"
@@ -475,8 +837,143 @@ async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, t
f"充值成功({order.credits}积分)",
related_id=order.id,
)
await db.flush()
await db.commit()
logger.info(
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
)
async def process_refund(
db: AsyncSession,
order_no: str,
refund_amount: float | None = None,
refund_reason: str = "管理员退款"
) -> dict:
"""Process a refund for a paid order.
Args:
db: async database session
order_no: merchant order number
refund_amount: amount to refund (defaults to full order amount)
refund_reason: reason for refund
Returns:
dict with refund result
"""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order:
return {"success": False, "message": "订单不存在"}
if order.status != "paid":
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
if order.refunded_at is not None:
return {"success": False, "message": "订单已退款"}
refund_amount = refund_amount or order.amount
# 金额校验
if refund_amount > order.amount:
return {"success": False, "message": "退款金额超过订单金额"}
# 如果是支付宝订单,调用支付宝退款API
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
refund_result = await _refund_alipay_order(
db, order, refund_amount, refund_reason, db_configs
)
if not refund_result.get("success"):
return refund_result
# 扣除积分
try:
await deduct_credits(
db,
order.user_id,
order.credits,
refund_reason,
related_id=order.id,
)
except Exception as e:
logger.exception(f"Failed to deduct credits for refund: {e}")
return {"success": False, "message": "积分扣除失败"}
# 更新订单状态
order.status = "refunded"
order.refund_amount = refund_amount
order.refunded_at = datetime.now()
if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "")
await db.commit()
logger.info(
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
f"refund_amount={refund_amount}"
)
return {"success": True, "message": "退款成功"}
async def _refund_alipay_order(
db: AsyncSession,
order: PaymentOrder,
refund_amount: float,
refund_reason: str,
db_configs: dict[str, str]
) -> dict:
"""Call Alipay refund API."""
app_id = db_configs.get("payment_alipay_app_id", "")
private_key = db_configs.get("payment_alipay_private_key", "")
public_key = db_configs.get("payment_alipay_public_key", "")
gateway = db_configs.get("payment_alipay_gateway", "")
client = _get_alipay_client(app_id, private_key, public_key, gateway)
if client is None:
return {"success": False, "message": "支付宝客户端初始化失败"}
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
logger.info(f"Mock mode: skipping alipay refund for {order.order_no}")
return {"success": True}
try:
from alipay.aop.api.domain.AlipayTradeRefundModel import AlipayTradeRefundModel
from alipay.aop.api.request.AlipayTradeRefundRequest import AlipayTradeRefundRequest
from alipay.aop.api.response.AlipayTradeRefundResponse import AlipayTradeRefundResponse
model = AlipayTradeRefundModel()
model.out_trade_no = order.order_no
model.refund_amount = f"{refund_amount:.2f}"
model.refund_reason = refund_reason
model.out_request_no = f"{order.order_no}_refund_{int(datetime.now().timestamp())}"
request = AlipayTradeRefundRequest(biz_model=model)
response_content = client.execute(request)
if not response_content:
logger.error(f"Alipay refund failed: empty response, order_no={order.order_no}")
return {"success": False, "message": "支付宝退款响应为空"}
response = AlipayTradeRefundResponse()
response.parse_response_content(response_content)
if response.is_success():
logger.info(f"Alipay refund succeeded: order_no={order.order_no}")
return {"success": True, "trade_no": response.trade_no}
else:
logger.error(
f"Alipay refund failed: code={response.code}, "
f"msg={response.msg}, sub_code={response.sub_code}, "
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
)
return {
"success": False,
"message": f"支付宝退款失败: {response.sub_msg or response.msg}"
}
except Exception as e:
logger.exception(f"Alipay refund exception: order_no={order.order_no}, {e}")
return {"success": False, "message": f"支付宝退款异常: {str(e)}"}
@@ -62,6 +62,9 @@ async def create_user_oauth_app(
secret: str,
open_type: int,
create_by: str | None = None,
count: int = 100,
auth_url: str | None = None,
company: str | None = None,
) -> UserOAuthApp:
existing = await get_user_oauth_app_by_app_id(db, app_id)
if existing:
@@ -72,6 +75,9 @@ async def create_user_oauth_app(
app_id=app_id,
secret=secret,
open_type=open_type,
count=count,
auth_url=auth_url,
company=company,
create_by=create_by,
)
db.add(app)
@@ -87,6 +93,9 @@ async def update_user_oauth_app(
secret: str | None = None,
open_type: int | None = None,
status: int | None = None,
count: int | None = None,
auth_url: str | None = None,
company: str | None = None,
create_by: str | None = None,
) -> UserOAuthApp | None:
app = await get_user_oauth_app_by_id(db, id)
@@ -99,6 +108,12 @@ async def update_user_oauth_app(
app.open_type = open_type
if status is not None:
app.status = status
if count is not None:
app.count = count
if auth_url is not None:
app.auth_url = auth_url
if company is not None:
app.company = company
if create_by is not None:
app.create_by = create_by
+9 -4
View File
@@ -1,5 +1,7 @@
import time
import random
from datetime import datetime
import secrets
def generate_id() -> str:
@@ -10,7 +12,10 @@ def generate_id() -> str:
def generate_order_no() -> str:
"""Generate a human-readable order number."""
timestamp = int(time.time())
randomness = random.randint(1000, 9999)
return f"VG{timestamp}{randomness}"
"""Generate a human-readable order number with yyyymmddhhmmss format."""
# 格式化为 yyyymmddhhmmss 格式的时间戳
now = datetime.now()
timestamp = now.strftime("%Y%m%d%H%M%S")
# 使用密码学安全的随机数生成 8位纯数字,防止并发冲突
random_part = ''.join(str(secrets.randbelow(10)) for _ in range(8))
return f"MZZC{timestamp}{random_part}"
+28 -65
View File
@@ -2,18 +2,14 @@
* API abstraction layer.
* Switches between mock data and real backend based on VITE_USE_MOCK env var.
*/
import { api, setToken, clearToken } from './client';
import * as mock from './mock';
import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
} from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>('/auth/login', { username, password, captcha_token: captchaToken, remember_me: rememberMe || false }, false);
@@ -26,19 +22,16 @@ export async function phonelogin(phone: string, code: string): Promise<User> {
setToken(res.accessToken);
return res.user;
}
export async function register(phone: string, code: string, password: string): Promise<User> {
const res = await api.post<{ accessToken: string; user: User }>('/auth/register', { phone, code, password }, false);
setToken(res.accessToken);
return res.user;
}
export async function logout(): Promise<void> {
if (USE_MOCK) return mock.mockLogout();
await api.post('/auth/logout');
clearToken();
}
export async function getUser(): Promise<User | null> {
if (USE_MOCK) return mock.mockGetUser();
try {
@@ -47,38 +40,30 @@ export async function getUser(): Promise<User | null> {
return null;
}
}
export async function changePassword(oldPwd: string, newPwd: string): Promise<void> {
if (USE_MOCK) return;
await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd });
}
// ── Projects ──────────────────────────────────────────────
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
return api.get<Project[]>('/projects');
}
export async function createProject(name: string, industry: Industry): Promise<Project> {
if (USE_MOCK) return mock.mockCreateProject(name, industry);
return api.post<Project>('/projects', { name, industry });
}
export async function deleteProject(id: string): Promise<void> {
if (USE_MOCK) return mock.mockDeleteProject(id);
await api.delete(`/projects/${id}`);
}
// ── Generation ────────────────────────────────────────────
export interface GenerationRecordPageListOut {
page: number;
pageSize: number;
total: number;
items: GenerationRecord[];
}
export interface GetRecordsPageParams {
projectId?: string;
status?: string;
@@ -86,7 +71,6 @@ export interface GetRecordsPageParams {
pageSize?: number;
signal?: AbortSignal;
}
export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise<GenerationRecordPageListOut> {
const page = params.page && params.page > 0 ? params.page : 1;
const pageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 10;
@@ -105,7 +89,6 @@ export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise
items: filtered.slice(start, start + pageSize),
};
}
const query = new URLSearchParams();
if (params.projectId) query.set('project_id', params.projectId);
if (params.status) query.set('status', params.status);
@@ -114,7 +97,6 @@ export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`, { signal: params.signal });
}
export async function optimizePrompt(
projectId: string, params: OptimizeParams
): Promise<OptimizeResult> {
@@ -131,7 +113,6 @@ export async function optimizePrompt(
image_px: params.image_px || null,
});
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
@@ -145,7 +126,6 @@ export async function uploadImage(file: File): Promise<{ url: string; filename:
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
@@ -159,15 +139,12 @@ export async function uploadVideo(file: File): Promise<{ url: string; filename:
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function deleteUpload(url: string): Promise<void> {
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
}
export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise<void> {
await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt });
}
export async function generateVideo(recordId: string, params: GenerateParams): Promise<GenerationRecord> {
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`, {
@@ -175,36 +152,27 @@ export async function generateVideo(recordId: string, params: GenerateParams): P
resolution: params.resolution,
});
}
// ── Credits ───────────────────────────────────────────────
export async function getCredits(): Promise<{ credits: number; records: CreditRecord[] }> {
if (USE_MOCK) return mock.mockGetCredits();
return api.get('/credits');
}
// ── Captcha ───────────────────────────────────────────────
export async function getSliderCaptcha(): Promise<{ captcha_id: string; bg_image: string; slider_image: string }> {
if (USE_MOCK) return { captcha_id: 'mock', bg_image: '', slider_image: '' };
return api.get('/captcha/slider', false);
}
export async function verifyCaptcha(captchaId: string, x: number): Promise<string> {
if (USE_MOCK) return 'mock-token';
const res = await api.post<{ token: string }>('/captcha/verify', { captcha_id: captchaId, x_offset: x }, false);
return res.token;
}
// ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementUrl: string; privacyPolicyUrl: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' };
return api.get('/auth/site-info', false);
}
// ── Video Engines ─────────────────────────────────────────
export async function getVideoEngines(): Promise<{ items: { id: string; name: string; provider: string; supportedRatios: string[]; supportedResolutions: string[]; supportedDurations: number[] }[] }> {
if (USE_MOCK) return { items: [{ id: 'mock', name: 'Seedance', provider: 'seedance', supportedRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], supportedResolutions: ['480p', '720p', '1080p'], supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }] };
return api.get('/video-engines');
@@ -213,89 +181,69 @@ export async function getVideoEngines(): Promise<{ items: { id: string; name: st
export async function getParameters(): Promise<any[]> {
return api.get('/image-engines');
}
// ── SMS ───────────────────────────────────────────────────
export async function sendSms(phone: string, scene: string): Promise<void> {
if (USE_MOCK) return;
await api.post('/sms/send', { phone, scene: scene }, false);
}
export async function verifySms(phone: string, code: string): Promise<{ token: string }> {
if (USE_MOCK) return { token: 'mock-sms-token' };
return api.post('/sms/verify', { phone, code }, false);
}
// ── Notifications ─────────────────────────────────────────
export async function getNotifications(): Promise<AdminNotification[]> {
if (USE_MOCK) return mock.mockGetAdminNotifications();
return api.get('/notifications');
}
export async function getUnreadCount(): Promise<number> {
if (USE_MOCK) return mock.mockGetAdminNotifications().then(n => n.filter(x => !x.isRead).length);
const res = await api.get<{ count: number }>('/notifications/unread-count');
return res.count;
}
export async function markNotificationRead(id: string): Promise<void> {
if (USE_MOCK) return;
await api.put(`/notifications/${id}/read`);
}
// ── Admin ─────────────────────────────────────────────────
export async function getAdminStats(): Promise<AdminStats> {
if (USE_MOCK) return mock.mockGetAdminStats();
return api.get('/admin/stats');
}
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
if (USE_MOCK) return mock.mockGetAdminUsers(search);
const q = search ? `?search=${encodeURIComponent(search)}` : '';
return api.get(`/admin/users${q}`);
}
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description);
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
export async function toggleUserStatus(userId: string, isActive: boolean): Promise<void> {
if (USE_MOCK) return mock.mockToggleUserStatus(userId, isActive);
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
}
export async function getModelConfigs(): Promise<ModelConfig[]> {
if (USE_MOCK) return mock.mockGetModelConfigs();
return api.get('/admin/model-configs');
}
export async function saveModelConfig(config: Partial<ModelConfig> & { id?: string }): Promise<ModelConfig> {
if (USE_MOCK) return mock.mockSaveModelConfig(config as any);
if (config.id) return api.put(`/admin/model-configs/${config.id}`, config);
return api.post('/admin/model-configs', config);
}
export async function deleteModelConfig(id: string): Promise<void> {
if (USE_MOCK) return mock.mockDeleteModelConfig(id);
await api.delete(`/admin/model-configs/${id}`);
}
export async function getSystemConfigs(): Promise<SystemConfig[]> {
if (USE_MOCK) return mock.mockGetSystemConfigs();
return api.get('/admin/system-configs');
}
export async function updateSystemConfig(id: string, value: string): Promise<void> {
if (USE_MOCK) return mock.mockUpdateSystemConfig(id, value);
await api.put(`/admin/system-configs/${id}`, { value });
}
// ── Industries ─────────────────────────────────────────────
export async function getIndustries(): Promise<IndustryConfig[]> {
const data = await api.get<any[]>('/industries');
return data.map((item: any) => {
@@ -306,15 +254,11 @@ export async function getIndustries(): Promise<IndustryConfig[]> {
return { ...item, optionGroups };
});
}
// ── Menu Config ────────────────────────────────────────────
export async function getMenuConfigs(): Promise<any[]> {
return api.get('/menu-configs');
}
// ── Recharge Packages ──────────────────────────────────────
export async function getRechargePackages(): Promise<any[]> {
return api.get('/recharge-packages');
}
@@ -331,6 +275,10 @@ export async function getPaymentOrders(): Promise<any[]> {
return api.get('/payments/orders');
}
export async function getPaymentOrder(orderNo: string): Promise<any> {
return api.get(`/payments/orders/${orderNo}`);
}
export async function cancelPaymentOrder(orderNo: string): Promise<void> {
return api.post(`/payments/orders/${orderNo}/cancel`);
}
@@ -342,10 +290,7 @@ export async function getCreditRatios(): Promise<any[]> {
export async function getEngine(): Promise<any[]> {
return api.get('/generation-ai/engines');
}
// ── Generation AI Tasks ────────────────────────────────────
// 创建ai生成任务
export async function createGenerationTask(params: any): Promise<any> {
return api.post('/generation-ai/tasks', params);
@@ -362,20 +307,38 @@ export async function gethistory(Pagebreak: any): Promise<any[]> {
export async function gethistoryItems(Pagebreak: any): Promise<any[]> {
return api.get('/generation-ai/history/'+Pagebreak);
}
// 删除ai对话历史记录
export async function deleteHistory(id: string): Promise<void> {
await api.delete(`/generation-ai/tasks/${id}`);
}
export async function calculateCredits(): Promise<any[]> {
return api.get('/credits/credit-ratios');
}
// 获取验证码
export async function getSendcode(phone: string): Promise<any> {
return api.post('/sms/send', { phone });
}
export interface OAuthAppParam {
page: number;
pageSize: number;
open_type?: string;
status?: string;
app_id?: string;
}
export interface OAuthAppList {
page: number;
pageSize: number;
total: number;
data: any[];
}
// 获取用户列表
export async function getAuthorizationList(params: OAuthAppParam): Promise<OAuthAppList> {
const query = new URLSearchParams();
query.set('page', String(params.page));
query.set('page_size', String(params.pageSize));
if (params.open_type) query.set('open_type', params.open_type);
if (params.status) query.set('status', params.status);
if (params.app_id) query.set('app_id', params.app_id);
return api.get<OAuthAppList>(`/admin/user-oauth-apps/list?${query.toString()}`);
}
+134 -26
View File
@@ -27,7 +27,7 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
import NotificationPopup from '../NotificationPopup';
interface MenuConfig {
@@ -92,9 +92,14 @@ const AppLayout: React.FC = () => {
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
const [paying, setPaying] = useState(false);
const [countdown, setCountdown] = useState(180); // 默认180秒超时
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
// LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order';
// 监听预览弹窗状态,关闭浮动按钮
useEffect(() => {
@@ -120,6 +125,57 @@ const AppLayout: React.FC = () => {
}).catch(() => {});
};
// 检查并恢复待处理的支付订单
useEffect(() => {
const checkPendingOrder = async () => {
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
if (savedOrderStr) {
try {
const savedOrder = JSON.parse(savedOrderStr);
// 查询订单状态
const order = await getPaymentOrder(savedOrder.orderNo);
if (order.status === 'pending') {
// 订单仍然待支付,恢复弹窗
setCurrentPaymentInfo({
price: savedOrder.price,
credits: savedOrder.credits,
qrCode: savedOrder.qrCode,
method: savedOrder.method,
});
currentOrderNoRef.current = savedOrder.orderNo;
// 计算剩余时间
const now = Date.now();
const createdAt = new Date(savedOrder.createdAt).getTime();
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
const elapsedSeconds = Math.floor((now - createdAt) / 1000);
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
if (remainingSeconds > 0) {
setQrCodeModalOpen(true);
startPolling(savedOrder.orderNo, remainingSeconds);
} else {
// 已超时,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} else if (order.status === 'paid') {
// 已支付
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
localStorage.removeItem(PENDING_ORDER_KEY);
} else {
// 订单已取消或其他状态,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// 查询失败,清除
localStorage.removeItem(PENDING_ORDER_KEY);
}
}
};
checkPendingOrder();
}, []);
useEffect(() => {
getMenuConfigs().then(data => {
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
@@ -190,41 +246,61 @@ const AppLayout: React.FC = () => {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, []);
const startPolling = useCallback((orderNo: string) => {
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
stopPolling();
let attempts = 0;
const maxAttempts = 120; // 2 minutes at 1s interval
const timer = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(timer);
pollingTimerRef.current = null;
return;
}
setCountdown(timeoutSeconds);
// 订单状态轮询(每2秒查询一次,只查询当前订单
const pollingTimer = setInterval(async () => {
try {
const orders = await getPaymentOrders();
const order = orders.find((o: any) => o.order_no === orderNo);
if (order && order.status === 'paid') {
clearInterval(timer);
pollingTimerRef.current = null;
const order = await getPaymentOrder(orderNo);
if (order.status === 'paid') {
stopPolling();
currentOrderNoRef.current = null;
localStorage.removeItem(PENDING_ORDER_KEY);
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
} else if (order && order.status === 'cancelled') {
clearInterval(timer);
pollingTimerRef.current = null;
} else if (order.status === 'cancelled') {
stopPolling();
currentOrderNoRef.current = null;
localStorage.removeItem(PENDING_ORDER_KEY);
}
} catch {
// ignore polling errors
}
}, 2000);
pollingTimerRef.current = pollingTimer;
// 倒计时
const countdownTimer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
// 超时自动取消
stopPolling();
if (currentOrderNoRef.current) {
cancelPaymentOrder(currentOrderNoRef.current).catch(() => {});
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
message.warning('订单已超时,请重新充值');
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
return 0;
}
return prev - 1;
});
}, 1000);
pollingTimerRef.current = timer;
countdownTimerRef.current = countdownTimer;
}, [stopPolling]);
return (
@@ -617,19 +693,32 @@ const AppLayout: React.FC = () => {
try {
setPaying(true);
const order = await createRechargeOrder(plan.id, paymentMethod);
if (paymentMethod === 'alipay' && order.qr_url) {
if (order.paymentMethod === 'alipay' && order.qrUrl) {
// Alipay: show the real QR code URL from the backend
setCurrentPaymentInfo({
const paymentInfo = {
price: plan.price,
credits: totalCredits,
qrCode: order.qr_url,
qrCode: order.qrUrl,
method: 'alipay',
});
};
setCurrentPaymentInfo(paymentInfo);
setRechargeModalOpen(false);
setQrCodeModalOpen(true);
currentOrderNoRef.current = order.order_no;
currentOrderNoRef.current = order.orderNo;
// 保存到 localStorage
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo,
price: plan.price,
credits: totalCredits,
qrCode: order.qrUrl,
method: 'alipay',
createdAt: order.createdAt || new Date().toISOString(),
timeoutSeconds: 180,
}));
// Start polling for payment status
startPolling(order.order_no);
startPolling(order.orderNo);
} else {
// WeChat or mock mode (mock auto-completes, no QR needed)
message.success('充值成功!积分已到账');
@@ -664,6 +753,7 @@ const AppLayout: React.FC = () => {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
}}
@@ -744,6 +834,23 @@ const AppLayout: React.FC = () => {
}}>
{currentPaymentInfo?.credits || 0}
</div>
{/* 倒计时显示 */}
<div style={{
marginTop: 12,
padding: '8px 16px',
background: countdown <= 30 ? '#fef2f2' : '#f0f9ff',
borderRadius: 8,
border: countdown <= 30 ? '1px solid #fecaca' : '1px solid #bae6fd',
display: 'inline-block',
}}>
<span style={{
fontSize: 14,
fontWeight: 600,
color: countdown <= 30 ? '#dc2626' : '#0284c7',
}}>
<span style={{ fontSize: 16, fontWeight: 800 }}>{countdown}</span>
</span>
</div>
</div>
</div>
@@ -772,6 +879,7 @@ const AppLayout: React.FC = () => {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
+118 -62
View File
@@ -1,12 +1,16 @@
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
import { Button, Table, Checkbox, Tag, Space, message, Modal } from 'antd';
import { PlusOutlined, CheckCircleOutlined, ClockCircleOutlined, CiCircleOutlined, EyeOutlined, XOutlined } from '@ant-design/icons';
// 模拟授权数据
const mockAuthorizations = [
{ id: '1867060028363785', status: 'active', description: '用户张三的API授权' },
{ id: '1867059757929740', status: 'pending', description: '用户李四的API授权' },
{ id: '1867059808785418', status: 'active', description: '用户王五的API授权' },
];
import type { OAuthAppParam } from '../api/index';
import { getAuthorizationList } from '../api/index';
// 授权数据类型
interface AuthorizationData {
id: string;
status: string;
description: string;
}
// 状态配置
const statusConfig = {
active: { label: '已授权', color: 'green', icon: CheckCircleOutlined },
@@ -30,12 +34,60 @@ const consumptionTypeConfig = {
audio: { label: '音频转换', color: 'purple' },
image: { label: '图片处理', color: 'green' },
};
// 模拟表头接口返回数据
const mockTableHeaderResponse = {
code: 200,
message: 'success',
data: [
{ title: '序号', dataIndex: 'index', key: 'index', width: 80, fixed: 'left' },
{ title: '消耗ID', dataIndex: 'id', key: 'id', ellipsis: true },
{ title: '授权ID', dataIndex: 'authorizationId', key: 'authorizationId', ellipsis: true },
{ title: '消耗类型', dataIndex: 'type', key: 'type', width: 120 },
{ title: '消耗金额', dataIndex: 'amount', key: 'amount', width: 120 },
{ title: '消耗描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '消耗时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, fixed: 'right' },
],
};
// 模拟获取表头接口
const fetchTableHeader = () => {
return new Promise<typeof mockTableHeaderResponse>((resolve) => {
setTimeout(() => {
resolve(mockTableHeaderResponse);
}, 500);
});
};
const AuthorizationPage: React.FC = () => {
const [authorizations, setAuthorizations] = useState(mockAuthorizations);
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [showConsumptionModal, setShowConsumptionModal] = useState(false);
const [consumptionRecords, setConsumptionRecords] = useState(mockConsumptionRecords);
const [consumptionColumns, setConsumptionColumns] = useState<typeof mockTableHeaderResponse['data']>([]);
const [headerLoading, setHeaderLoading] = useState(false);
// 页面初始化时获取授权列表
useEffect(() => {
const loadData = async () => {
setListLoading(true);
try {
const params: OAuthAppParam = {
page: 1,
pageSize: 10,
};
const response = await getAuthorizationList(params);
setAuthorizations(response.data || []);
} catch (error) {
message.error('获取授权列表失败');
} finally {
setListLoading(false);
}
};
loadData();
}, []);
// 状态标签渲染
const renderStatus = (status: string) => {
@@ -97,7 +149,7 @@ const AuthorizationPage: React.FC = () => {
dataIndex: 'operation',
key: 'operation',
width: 120,
render: (_: any, record: typeof mockAuthorizations[0]) => (
render: (_: any, record: AuthorizationData) => (
<Button
type="primary"
size="small"
@@ -106,7 +158,7 @@ const AuthorizationPage: React.FC = () => {
// background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
}}
onClick={() => setShowConsumptionModal(true)}
onClick={handleOpenConsumptionModal}
>
</Button>
@@ -114,6 +166,60 @@ const AuthorizationPage: React.FC = () => {
}
];
// 获取表头数据
const handleFetchHeader = async () => {
setHeaderLoading(true);
try {
const response = await fetchTableHeader();
if (response.code === 200) {
// 对特定列添加render函数
const columnsWithRender = response.data.map(col => {
if (col.dataIndex === 'index') {
return {
...col,
render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span>,
};
}
if (col.dataIndex === 'id') {
return {
...col,
render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span>,
};
}
if (col.dataIndex === 'type') {
return {
...col,
render: (text: string) => {
const config = consumptionTypeConfig[text as keyof typeof consumptionTypeConfig];
return <Tag color={config?.color}>{config?.label}</Tag>;
},
};
}
if (col.dataIndex === 'amount') {
return {
...col,
render: (text: number) => <span style={{ color: '#ef4444', fontWeight: 500 }}>{text} </span>,
};
}
return col;
});
setConsumptionColumns(columnsWithRender);
} else {
message.error(response.message);
}
} catch (error) {
message.error('获取表头失败');
} finally {
setHeaderLoading(false);
}
};
// 打开消耗弹窗
const handleOpenConsumptionModal = () => {
setShowConsumptionModal(true);
handleFetchHeader();
};
// 处理点击授权按钮
const handleAuthorize = () => {
if (selectedRowKeys.length === 0) {
@@ -134,14 +240,12 @@ const AuthorizationPage: React.FC = () => {
}, 800);
};
// 准备表格数据(添加序号)
const tableData = authorizations.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
// 准备消耗记录表格数据(添加序号)
const consumptionTableData = consumptionRecords.map((item, index) => ({
...item,
index: index + 1,
@@ -184,6 +288,7 @@ const AuthorizationPage: React.FC = () => {
<Table
dataSource={tableData}
columns={columns}
loading={listLoading}
pagination={{
pageSize: 10,
showSizeChanger: true,
@@ -213,57 +318,8 @@ const AuthorizationPage: React.FC = () => {
<div style={{ padding: 16 }}>
<Table
dataSource={consumptionTableData}
columns={[
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 80,
render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span>,
},
{
title: '消耗ID',
dataIndex: 'id',
key: 'id',
ellipsis: true,
render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span>,
},
{
title: '授权ID',
dataIndex: 'authorizationId',
key: 'authorizationId',
ellipsis: true,
},
{
title: '消耗类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (text: string) => {
const config = consumptionTypeConfig[text as keyof typeof consumptionTypeConfig];
return <Tag color={config?.color}>{config?.label}</Tag>;
},
},
{
title: '消耗金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (text: number) => <span style={{ color: '#ef4444', fontWeight: 500 }}>{text} </span>,
},
{
title: '消耗描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '消耗时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
},
]}
columns={consumptionColumns}
loading={headerLoading}
pagination={{
pageSize: 10,
showSizeChanger: true,
@@ -1,175 +0,0 @@
import React, { useState } from 'react';
import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
interface CreditRatio {
id: string;
modelName: string;
resolution: string;
ratio: number;
baseCredits: number;
perSecondCredits: number;
}
const MOCK_RATIOS: CreditRatio[] = [
{ id: 'cr-1', modelName: 'GPT-4o', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
{ id: 'cr-2', modelName: 'GPT-4o', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
{ id: 'cr-3', modelName: 'GPT-4o', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
{ id: 'cr-4', modelName: 'DeepSeek-V3', resolution: '720p', ratio: 0.8, baseCredits: 48, perSecondCredits: 2 },
{ id: 'cr-5', modelName: 'DeepSeek-V3', resolution: '1080p', ratio: 1.2, baseCredits: 72, perSecondCredits: 3 },
{ id: 'cr-6', modelName: 'DeepSeek-V3', resolution: '4K', ratio: 2.0, baseCredits: 120, perSecondCredits: 4 },
{ id: 'cr-7', modelName: '通用', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
{ id: 'cr-8', modelName: '通用', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
{ id: 'cr-9', modelName: '通用', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
];
const AdminCreditRatios: React.FC = () => {
const [ratios, setRatios] = useState<CreditRatio[]>(MOCK_RATIOS);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
if (modal.ratio) {
setRatios(prev => prev.map(r => r.id === modal.ratio!.id ? { ...r, ...values } : r));
message.success('已更新');
} else {
setRatios(prev => [...prev, { id: `cr-${Date.now()}`, ...values }]);
message.success('已添加');
}
setModal({ open: false, ratio: null });
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setRatios(prev => prev.filter(r => r.id !== id));
message.success('已删除');
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
if (ratio) form.setFieldsValue(ratio);
else { form.resetFields(); form.setFieldsValue({ ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }); }
};
const columns = [
{
title: '模型', dataIndex: 'modelName', width: 150,
render: (v: string) => <Tag color="purple">{v}</Tag>,
},
{
title: '分辨率', dataIndex: 'resolution', width: 100,
render: (v: string) => {
const colors: Record<string, string> = { '720p': 'default', '1080p': 'blue', '4K': 'gold' };
return <Tag color={colors[v] || 'default'}>{v}</Tag>;
},
},
{
title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio,
render: (v: number) => (
<Typography.Text strong style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>
x{v}
</Typography.Text>
),
},
{
title: '基础积分', dataIndex: 'baseCredits', width: 100,
render: (v: number) => <Typography.Text>{v} </Typography.Text>,
},
{
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
},
{
title: '示例计算 (15秒)', key: 'example', width: 120,
render: (_: any, r: CreditRatio) => {
const total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: CreditRatio) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{ratios.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
( + x ) x
</Typography.Text>
<Table
columns={columns}
dataSource={ratios}
rowKey="id"
pagination={false}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title={<Space><CalculatorOutlined />{modal.ratio ? '编辑比例' : '添加比例'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={480}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="modelName" label="模型" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'GPT-4o', label: 'GPT-4o' },
{ value: 'DeepSeek-V3', label: 'DeepSeek-V3' },
{ value: '通用', label: '通用 (默认)' },
]} />
</Form.Item>
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminCreditRatios;
@@ -1,143 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, DatePicker, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, SearchOutlined,
} from '@ant-design/icons';
interface CreditRecord {
id: string;
username: string;
type: 'recharge' | 'consume';
amount: number;
balanceAfter: number;
description: string;
createdAt: string;
}
const MOCK_RECORDS: CreditRecord[] = [
{ id: 'cr-1', username: 'videomaker', type: 'recharge', amount: 3000, balanceAfter: 3000, description: '会员充值赠送', createdAt: '2026-04-28 10:00:00' },
{ id: 'cr-2', username: 'videomaker', type: 'consume', amount: -120, balanceAfter: 2880, description: '提示词优化 - 电商广告视频', createdAt: '2026-04-29 14:22:00' },
{ id: 'cr-3', username: 'designer', type: 'recharge', amount: 2000, balanceAfter: 2000, description: '进阶包充值', createdAt: '2026-04-29 16:00:00' },
{ id: 'cr-4', username: 'videomaker', type: 'consume', amount: -80, balanceAfter: 2800, description: '提示词优化 - 教育课程视频', createdAt: '2026-04-30 09:15:00' },
{ id: 'cr-5', username: 'designer', type: 'consume', amount: -100, balanceAfter: 1900, description: '提示词优化 - 品牌故事视频', createdAt: '2026-05-01 11:30:00' },
{ id: 'cr-6', username: 'marketer', type: 'recharge', amount: 5000, balanceAfter: 5000, description: '专业包充值', createdAt: '2026-05-02 08:00:00' },
{ id: 'cr-7', username: 'videomaker', type: 'recharge', amount: 500, balanceAfter: 3300, description: '活动赠送积分', createdAt: '2026-05-02 11:00:00' },
{ id: 'cr-8', username: 'marketer', type: 'consume', amount: -120, balanceAfter: 4880, description: '提示词优化 - 产品宣传视频', createdAt: '2026-05-03 15:20:00' },
{ id: 'cr-9', username: 'editor', type: 'recharge', amount: 1500, balanceAfter: 1500, description: '体验包充值', createdAt: '2026-05-04 10:00:00' },
{ id: 'cr-10', username: 'designer', type: 'consume', amount: -200, balanceAfter: 1700, description: '提示词优化 - 游戏预告片', createdAt: '2026-05-05 14:45:00' },
];
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<CreditRecord[]>(MOCK_RECORDS);
const [typeFilter, setTypeFilter] = useState<string>('');
const [loading, setLoading] = useState(false);
const filtered = typeFilter ? records.filter(r => r.type === typeFilter) : records;
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
const columns = [
{
title: '用户', dataIndex: 'username', width: 120,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'type', width: 100,
render: (v: string) => (
<Tag color={v === 'recharge' ? 'green' : 'red'} icon={v === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
{v === 'recharge' ? '充值' : '消费'}
</Tag>
),
filters: [
{ text: '充值', value: 'recharge' },
{ text: '消费', value: 'consume' },
],
onFilter: (value: any, record: CreditRecord) => record.type === value,
},
{
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v > 0 ? '+' : ''}{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
},
{
title: '说明', dataIndex: 'description', ellipsis: true,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
];
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(16,185,129,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#10b981',
}}><ArrowUpOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#ef4444',
}}><ArrowDownOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}><WalletOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{records.length}</div>
</div>
</div>
</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
columns={columns}
dataSource={filtered}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条记录` }}
scroll={{ x: 800 }}
/>
</Card>
</div>
);
};
export default AdminCreditRecords;
@@ -1,116 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
DollarOutlined,
ThunderboltOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
import { getAdminStats } from '../../api';
import type { AdminStats } from '../../types';
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const data = await getAdminStats();
setStats(data);
setLoading(false);
};
load();
}, []);
const statCards = stats ? [
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
] : [];
return (
<div>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((s, i) => (
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
<Card bordered={false} loading={loading}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: s.bg, display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: s.color, flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
{/* Quick Info */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="系统信息" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ label: '平台名称', value: 'VideoGen.AI' },
{ label: 'API版本', value: 'v1.0.0' },
{ label: '数据库', value: 'SQLite (本地开发)' },
{ label: 'LLM模式', value: 'Mock (模拟)' },
{ label: '视频引擎', value: 'Seedance 2.0' },
].map(item => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<Typography.Text type="secondary">{item.label}</Typography.Text>
<Typography.Text strong>{item.value}</Typography.Text>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="充值套餐" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1', hot: true },
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
].map(p => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
{p.hot && <Tag color="purple" style={{ fontSize: 10, lineHeight: '16px' }}></Tag>}
</div>
<div>
<Typography.Text strong style={{ color: p.color }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()}</Typography.Text>
</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default AdminDashboard;
@@ -1,336 +0,0 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined,
} from '@ant-design/icons';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
description: string;
skills: string[];
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
const MOCK_INDUSTRIES: IndustryItem[] = [
{
id: 'ind-1', key: 'ecommerce', label: '电商', description: '电商直播、产品展示、促销活动',
skills: ['你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言,注重画面节奏和消费者心理'],
optionGroups: [
{ name: '视频风格', options: ['实拍展示', '3D动画', '混剪快闪', '沉浸体验'] },
{ name: '目标受众', options: ['年轻女性', '家庭用户', '商务人士', '学生群体'] },
],
isActive: true, sortOrder: 1,
},
{
id: 'ind-2', key: 'education', label: '教育', description: '在线课程、知识付费、培训',
skills: ['你是一位专业的教育视频策划专家,擅长将复杂知识点转化为生动易懂的视觉叙事'],
optionGroups: [
{ name: '课程类型', options: ['知识讲解', '操作演示', '故事叙事', '互动问答'] },
],
isActive: true, sortOrder: 2,
},
{
id: 'ind-3', key: 'gaming', label: '游戏', description: '游戏预告、赛事宣传、角色展示',
skills: ['你是一位专业的游戏视频创意专家,擅长打造震撼视觉体验和沉浸式叙事'],
optionGroups: [
{ name: '游戏类型', options: ['RPG', 'FPS', 'MOBA', '休闲'] },
{ name: '视频类型', options: ['预告片', '宣传片', '教程', '赛事回顾'] },
],
isActive: true, sortOrder: 3,
},
{
id: 'ind-4', key: 'medical', label: '医疗', description: '医疗健康、药品宣传、科普',
skills: ['你是一位专业的医疗健康视频文案专家,擅长将医学知识转化为通俗易懂的视觉内容'],
optionGroups: [],
isActive: true, sortOrder: 4,
},
{
id: 'ind-5', key: 'finance', label: '金融', description: '理财产品、保险、银行服务',
skills: ['你是一位专业的金融视频文案专家,擅长将复杂的金融产品转化为易于理解的视觉表达'],
optionGroups: [],
isActive: true, sortOrder: 5,
},
{
id: 'ind-6', key: 'realestate', label: '房产', description: '楼盘展示、户型介绍、周边配套',
skills: ['你是一位专业的房产视频策划专家,擅长通过镜头语言展现空间美感和生活场景'],
optionGroups: [
{ name: '展示方式', options: ['航拍全景', '室内漫游', '样板间', '周边实景'] },
],
isActive: true, sortOrder: 6,
},
{
id: 'ind-7', key: 'food', label: '餐饮', description: '美食制作、餐厅宣传、食材展示',
skills: ['你是一位专业的美食视频创意专家,擅长用镜头捕捉食物的色香味,营造食欲感'],
optionGroups: [
{ name: '拍摄风格', options: ['特写慢放', '制作过程', '美食探店', '食材溯源'] },
],
isActive: true, sortOrder: 7,
},
{
id: 'ind-8', key: 'travel', label: '旅游', description: '景点宣传、酒店推荐、旅行攻略',
skills: ['你是一位专业的旅游视频文案专家,擅长用镜头语言展现目的地魅力和旅行体验'],
optionGroups: [
{ name: '内容形式', options: ['Vlog', '攻略指南', '风景大片', '人文记录'] },
],
isActive: true, sortOrder: 8,
},
{
id: 'ind-9', key: 'tech', label: '科技', description: '科技产品、SaaS服务、AI应用',
skills: ['你是一位专业的科技视频策划专家,擅长将技术概念转化为直观的视觉演示'],
optionGroups: [
{ name: '演示方式', options: ['产品演示', '对比评测', '概念解析', '场景模拟'] },
],
isActive: true, sortOrder: 9,
},
{
id: 'ind-10', key: 'other', label: '其他', description: '通用行业',
skills: ['你是一位专业的视频导演和文案专家,擅长将主题转化为富有感染力的视觉叙事'],
optionGroups: [],
isActive: true, sortOrder: 10,
},
];
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>(MOCK_INDUSTRIES);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const skills = values.skills_wentutujie?.trim() ? [values.skills_wentutujie.trim()] : [];
const optionGroups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
if (modal.item) {
setIndustries(prev => prev.map(i => i.id === modal.item!.id ? { ...i, ...values, skills, optionGroups } : i));
message.success('已更新');
} else {
const newItem: IndustryItem = {
id: `ind-${Date.now()}`,
key: values.key,
label: values.label,
description: values.description || '',
skills,
optionGroups,
isActive: values.isActive !== false,
sortOrder: industries.length + 1,
};
setIndustries(prev => [...prev, newItem]);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = (id: string) => {
setIndustries(prev => prev.filter(i => i.id !== id));
message.success('已删除');
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
description: item.description,
skills_wentutujie: item.skills[0] || '',
optionGroups: item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }],
isActive: item.isActive,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 160,
render: (_: any, r: IndustryItem) => (
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
),
},
{
title: '描述', dataIndex: 'description', ellipsis: true,
},
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}></Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解提示词', dataIndex: 'skills', width: 200,
render: (skills: string[]) => (
<Typography.Text ellipsis style={{ fontSize: 12 }}>
{skills[0] || '-'}
</Typography.Text>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{industries.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}></Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
</Button>
</div>
)}
</Form.List>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -1,163 +0,0 @@
import React, { useState } from 'react';
import { Layout, Menu, Avatar, Typography, Space, Dropdown, Spin } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LeftOutlined,
RightOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAuthStore();
const [collapsed, setCollapsed] = useState(false);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/credit-records', icon: <WalletOutlined />, label: '交易流水' },
{ key: '/admin/models', icon: <RobotOutlined />, label: '模型配置' },
{ key: '/admin/credit-ratios', icon: <CalculatorOutlined />, label: '积分比例' },
{ key: '/admin/video-engines', icon: <PlayCircleOutlined />, label: '视频引擎' },
{ key: '/admin/industries', icon: <AppstoreOutlined />, label: '行业配置' },
{ key: '/admin/payment', icon: <DollarOutlined />, label: '支付配置' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/admin/notifications', icon: <BellOutlined />, label: '消息推送' },
];
const selectedKey = location.pathname;
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
{/* User block */}
<div style={{
position: 'absolute', bottom: 48, left: 0, right: 0,
padding: collapsed ? '12px 8px' : '12px 16px',
borderTop: '1px solid rgba(255,255,255,0.06)',
}}>
<Dropdown menu={{
items: [
{ key: 'front', icon: <ThunderboltOutlined />, label: '返回前台' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'front') navigate('/projects');
else if (key === 'logout') { logout(); navigate('/admin/login'); }
},
}} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10, padding: '8px 10px', borderRadius: 10,
cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
transition: 'background 0.2s',
}}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e2e8f0', fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.username}
</div>
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 10 }}></div>
</div>
)}
</div>
</Dropdown>
</div>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{menuItems.find(m => m.key === selectedKey)?.label || '管理后台'}
</Typography.Text>
<Space>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{user?.username}
</Typography.Text>
</Space>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
@@ -1,78 +0,0 @@
import { useState } from 'react';
import { Button, Card, Form, Input, message, Typography } from 'antd';
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const AdminLoginPage = () => {
const navigate = useNavigate();
const { login } = useAuthStore();
const [loading, setLoading] = useState(false);
const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success('登录成功');
navigate('/admin');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a35 50%, #0f0f23 100%)',
}}>
<Card bordered={false} style={{
width: 420, borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 56, height: 56, borderRadius: 14, margin: '0 auto 16px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}>
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
<Typography.Title level={3} style={{ margin: 0 }}>
VideoGen<span style={{ color: '#6366f1' }}>.AI</span>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Form onFinish={handleLogin} layout="vertical" initialValues={{ username: 'admin', password: 'admin123' }}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item style={{ marginBottom: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block size="large"
style={{
borderRadius: 10, fontWeight: 600, height: 44,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
}}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
演示账号: admin / admin123
</Typography.Text>
</div>
</Card>
</div>
);
};
export default AdminLoginPage;
@@ -1,222 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
RobotOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getModelConfigs, saveModelConfig, deleteModelConfig } from '../../api';
import type { ModelConfig } from '../../types';
const AdminModels: React.FC = () => {
const [models, setModels] = useState<ModelConfig[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<{ open: boolean; model: ModelConfig | null }>({ open: false, model: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
const data = await getModelConfigs();
setModels(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
await saveModelConfig({
...modal.model,
...values,
id: modal.model?.id,
});
message.success(modal.model?.id ? '模型配置已更新' : '模型配置已添加');
setModal({ open: false, model: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
await deleteModelConfig(id);
message.success('模型配置已删除');
load();
};
const openEdit = (model?: ModelConfig) => {
setModal({ open: true, model: model || null });
if (model) {
form.setFieldsValue(model);
} else {
form.resetFields();
form.setFieldsValue({
provider: 'sdk',
weight: 1,
maxTokens: 4096,
temperature: 0.7,
isActive: true,
priority: 0,
});
}
};
const columns = [
{
title: '模型名称', dataIndex: 'name', width: 150,
render: (v: string, r: ModelConfig) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 14,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontWeight: 600 }}>{v}</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</Space>
),
},
{
title: '提供商', dataIndex: 'provider', width: 140,
render: (v: string) => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
},
},
{
title: 'API地址', dataIndex: 'apiBase', width: 200,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{v || '-'}
</Typography.Text>
),
},
{
title: '权重', dataIndex: 'weight', width: 80, sorter: (a: ModelConfig, b: ModelConfig) => a.weight - b.weight,
},
{
title: 'Max Tokens', dataIndex: 'maxTokens', width: 100,
},
{
title: 'Temperature', dataIndex: 'temperature', width: 100,
render: (v: number) => v.toFixed(1),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
),
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ModelConfig) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />}
onClick={() => openEdit(r)}>
</Button>
<Popconfirm title="确定删除该模型配置?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={models}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
{/* Edit Modal */}
<Modal
title={<Space><RobotOutlined />{modal.model?.id ? '编辑模型' : '添加模型'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, model: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="显示名称"
rules={[{ required: true, message: '请输入模型名称' }]}>
<Input placeholder="例如:GPT-4o" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识' }]}>
<Input placeholder="例如:gpt-4o" size="large" />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址">
<Input placeholder="https://api.openai.com/v1" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="weight" label="权重" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={256} max={128000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ flex: 1, paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminModels;
@@ -1,166 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined,
} from '@ant-design/icons';
interface NotificationRecord {
id: string;
title: string;
content: string;
type: string;
target: string;
createdAt: string;
}
const MOCK_NOTIFICATIONS: NotificationRecord[] = [
{ id: 'n-1', title: '系统上线通知', content: 'VideoGen.AI 平台正式上线!', type: 'system', target: '全部用户', createdAt: '2026-05-01 09:00:00' },
{ id: 'n-2', title: '积分充值优惠', content: '限时活动:充值进阶包额外赠送200积分', type: 'credit', target: '全部用户', createdAt: '2026-05-03 10:00:00' },
{ id: 'n-3', title: '账户审核通过', content: '您的账户已通过实名审核', type: 'system', target: 'videomaker', createdAt: '2026-05-05 14:00:00' },
];
const MOCK_USERS = [
{ id: 'u-001', username: 'videomaker' },
{ id: 'u-002', username: 'designer' },
{ id: 'u-003', username: 'marketer' },
{ id: 'u-004', username: 'editor' },
];
const AdminNotificationManager: React.FC = () => {
const [notifications, setNotifications] = useState<NotificationRecord[]>(MOCK_NOTIFICATIONS);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const handleSend = async () => {
try {
const values = await form.validateFields();
const newRecord: NotificationRecord = {
id: `n-${Date.now()}`,
title: values.title,
content: values.content,
type: values.type,
target: values.target_user_id
? MOCK_USERS.find(u => u.id === values.target_user_id)?.username || '指定用户'
: '全部用户',
createdAt: new Date().toLocaleString('zh-CN'),
};
setNotifications(prev => [newRecord, ...prev]);
message.success('消息已发送');
setModalOpen(false);
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
message.success('已删除');
};
const getTypeColor = (type: string) => {
switch (type) {
case 'system': return 'blue';
case 'credit': return 'orange';
case 'promo': return 'purple';
default: return 'default';
}
};
const columns = [
{
title: '标题', dataIndex: 'title', width: 200,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
},
{
title: '类型', dataIndex: 'type', width: 80,
render: (v: string) => {
const labels: Record<string, string> = { system: '系统', credit: '积分', promo: '活动' };
return <Tag color={getTypeColor(v)}>{labels[v] || v}</Tag>;
},
},
{
title: '发送目标', dataIndex: 'target', width: 120,
render: (v: string) => (
<Tag color={v === '全部用户' ? 'green' : 'blue'}>{v}</Tag>
),
},
{
title: '发送时间', dataIndex: 'createdAt', width: 160,
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: NotificationRecord) => (
<Popconfirm title="确定删除该消息?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条消息` }}
scroll={{ x: 800 }}
/>
</Card>
{/* Send Notification Modal */}
<Modal
title={<Space><SendOutlined /></Space>}
open={modalOpen}
onOk={handleSend}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="发送" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="消息标题"
rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
initialValue="system" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'system', label: '系统通知' },
{ value: 'credit', label: '积分通知' },
{ value: 'promo', label: '活动通知' },
]} />
</Form.Item>
<Form.Item name="target_user_id" label="发送目标" style={{ flex: 1 }}
extra="留空则发送给全部用户">
<Select size="large" allowClear placeholder="全部用户"
options={MOCK_USERS.map(u => ({ value: u.id, label: u.username }))} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminNotificationManager;
@@ -1,114 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Empty, Space, Tag, Typography,
} from 'antd';
import {
BellOutlined, CheckOutlined, InfoCircleOutlined, CreditCardOutlined, ExclamationCircleOutlined,
} from '@ant-design/icons';
import { getNotifications } from '../../api';
import type { AdminNotification } from '../../types';
const AdminNotifications: React.FC = () => {
const [notifications, setNotifications] = useState<AdminNotification[]>([]);
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
const data = await getNotifications();
setNotifications(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const getTypeIcon = (type: string) => {
switch (type) {
case 'system': return <InfoCircleOutlined style={{ color: '#6366f1' }} />;
case 'credit': return <CreditCardOutlined style={{ color: '#f59e0b' }} />;
default: return <ExclamationCircleOutlined style={{ color: '#94a3b8' }} />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'system': return <Tag color="blue"></Tag>;
case 'credit': return <Tag color="orange"></Tag>;
default: return <Tag></Tag>;
}
};
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.filter(n => !n.isRead).length} </Tag>
</Space>
</div>
{notifications.length === 0 ? (
<Empty description="暂无通知" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{notifications.map(n => (
<Card
key={n.id}
size="small"
bordered
style={{
borderRadius: 10,
borderColor: n.isRead ? '#f0f0f5' : '#e0e7ff',
background: n.isRead ? '#fff' : '#fafbff',
transition: 'all 0.2s',
}}
>
<div style={{ display: 'flex', gap: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: n.isRead ? '#f8fafc' : 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18,
}}>
{getTypeIcon(n.type)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14 }}>
{n.title}
</Typography.Text>
{getTypeLabel(n.type)}
{!n.isRead && (
<Tag color="red" style={{ fontSize: 10 }}></Tag>
)}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 4, lineHeight: 1.6 }}>
{n.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{n.createdAt}
</Typography.Text>
</div>
{!n.isRead && (
<Button type="text" size="small" icon={<CheckOutlined />}
style={{ flexShrink: 0, color: '#6366f1' }}
onClick={() => {
setNotifications(prev =>
prev.map(item => item.id === n.id ? { ...item, isRead: true } : item)
);
}}>
</Button>
)}
</div>
</Card>
))}
</div>
)}
</Card>
</div>
);
};
export default AdminNotifications;
@@ -1,153 +0,0 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, message, Switch, Typography, Divider,
} from 'antd';
import {
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
} from '@ant-design/icons';
interface PaymentSetting {
key: string;
value: string;
label: string;
description: string;
secret?: boolean;
}
const AdminPaymentConfig: React.FC = () => {
const [saving, setSaving] = useState(false);
const [wechatEnabled, setWechatEnabled] = useState(false);
const [alipayEnabled, setAlipayEnabled] = useState(false);
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await new Promise(r => setTimeout(r, 500));
message.success('支付配置已保存');
setSaving(false);
} catch { setSaving(false); }
};
return (
<div style={{ maxWidth: 720 }}>
{/* WeChat Pay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(7,193,96,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#07c160',
}}><WechatOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={wechatEnabled} onChange={setWechatEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
wechat_mch_id: '',
wechat_api_key: '',
wechat_cert_path: '',
wechat_notify_url: '',
}}>
<Form.Item name="wechat_mch_id" label="商户号 (MchID)">
<Input placeholder="微信支付商户号" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_api_key" label="API密钥">
<Input.Password placeholder="微信支付API密钥" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_cert_path" label="证书路径">
<Input placeholder="apiclient_cert.pem 路径" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_notify_url" label="回调地址">
<Input placeholder="https://yourdomain.com/api/payments/wechat/callback" size="large" disabled={!wechatEnabled} />
</Form.Item>
</Form>
</Card>
{/* Alipay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(0,122,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#007aff',
}}><AlipayCircleOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={alipayEnabled} onChange={setAlipayEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
alipay_app_id: '',
alipay_private_key: '',
alipay_public_key: '',
alipay_gateway: '',
alipay_notify_url: '',
}}>
<Form.Item name="alipay_app_id" label="AppID">
<Input placeholder="支付宝应用AppID" size="large" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_private_key" label="应用私钥">
<Input.TextArea rows={3} placeholder="支付宝应用私钥 (PKCS8格式)" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_public_key" label="支付宝公钥">
<Input.TextArea rows={3} placeholder="支付宝公钥" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_gateway" label="网关地址" extra="正式环境: https://openapi.alipay.com/gateway.do 沙箱环境: https://openapi-sandbox.dl.alipaydev.com/gateway.do">
<Input placeholder="https://openapi.alipay.com/gateway.do" size="large" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_notify_url" label="回调地址" extra="用户支付成功后,支付宝会主动通知此地址,服务器收到通知后给用户加积分">
<Input placeholder="https://yourdomain.com/api/payments/alipay/callback" size="large" disabled={!alipayEnabled} />
</Form.Item>
</Form>
</Card>
{/* Recharge Packages */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<span><DollarOutlined style={{ marginRight: 8 }} /></span>}>
{[
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1' },
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
].map(p => (
<div key={p.name} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 0', borderBottom: '1px solid #f5f6fa',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
</div>
<div>
<Typography.Text strong style={{ color: p.color, fontSize: 16 }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()} </Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>({(p.price / p.credits * 100).toFixed(1)}/)</Typography.Text>
</div>
</div>
))}
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminPaymentConfig;
@@ -1,128 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Space, Typography,
} from 'antd';
import {
SettingOutlined, SaveOutlined,
} from '@ant-design/icons';
import { getSystemConfigs, updateSystemConfig } from '../../api';
import type { SystemConfig } from '../../types';
const AdminSettings: React.FC = () => {
const [configs, setConfigs] = useState<SystemConfig[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
const load = async () => {
setLoading(true);
const data = await getSystemConfigs();
setConfigs(data);
const formValues: Record<string, string> = {};
data.forEach(c => { formValues[c.key] = c.value; });
form.setFieldsValue(formValues);
setLoading(false);
};
load();
}, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
if (newVal !== config.value) {
await updateSystemConfig(config.id, newVal);
}
}
message.success('系统配置已保存');
const data = await getSystemConfigs();
setConfigs(data);
setSaving(false);
} catch {
setSaving(false);
}
};
const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
};
const getFieldDescription = (config: SystemConfig): string => {
const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表',
};
return descMap[config.key] || config.description || '';
};
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) {
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
}
return (
<div style={{ maxWidth: 720 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text>
</div>
</div>
<Form form={form} layout="vertical">
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
))}
</Form>
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminSettings;
@@ -1,182 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined,
} from '@ant-design/icons';
import { getAdminUsers, adjustCredits, toggleUserStatus } from '../../api';
import type { AdminUser } from '../../types';
const AdminUsers: React.FC = () => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
const data = await getAdminUsers(search || undefined);
setUsers(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const handleSearch = () => load();
const handleAdjustCredits = async () => {
try {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.reason);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
setCreditModal({ open: false, user: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleToggleStatus = async (user: AdminUser) => {
await toggleUserStatus(user.id, !user.isActive);
message.success(user.isActive ? '已禁用该用户' : '已启用该用户');
load();
};
const columns = [
{
title: '用户', key: 'user', width: 200,
render: (_: any, r: AdminUser) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isAdmin ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 13, fontWeight: 700,
}}>
{r.username.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600 }}>
{r.username}
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
</div>
</Space>
),
},
{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '手机号', dataIndex: 'phone', width: 130,
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '正常' : '禁用'}</Tag>
),
},
{
title: '注册时间', dataIndex: 'createdAt', width: 120,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
{
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v || '-'}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4}>
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button>
{!r.isAdmin && (
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
>
<Button type="link" size="small" danger={r.isActive}
icon={r.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{r.isActive ? '禁用' : '启用'}
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
{/* Search bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="搜索用户名或邮箱"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={search}
onChange={e => setSearch(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 280, borderRadius: 8 }}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}></Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 个用户` }}
scroll={{ x: 900 }}
/>
</Card>
{/* Adjust Credits Modal */}
<Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
/>
</Form.Item>
<Form.Item name="reason" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminUsers;
@@ -1,214 +0,0 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
interface VideoEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedRatios: string[];
supportedResolutions: string[];
maxDuration: number;
isActive: boolean;
priority: number;
}
const MOCK_ENGINES: VideoEngine[] = [
{
id: 've-1', name: 'Seedance 2.0', provider: 'seedance',
apiBase: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: 'sk-****', modelName: 'seedance-2.0',
supportedRatios: ['16:9', '9:16', '1:1', '4:3'],
supportedResolutions: ['720p', '1080p', '4K'],
maxDuration: 60, isActive: true, priority: 1,
},
];
const AdminVideoEngines: React.FC = () => {
const [engines, setEngines] = useState<VideoEngine[]>(MOCK_ENGINES);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
if (modal.engine) {
setEngines(prev => prev.map(e => e.id === modal.engine!.id ? { ...e, ...values } : e));
message.success('已更新');
} else {
const newEngine: VideoEngine = {
id: `ve-${Date.now()}`,
...values,
};
setEngines(prev => [...prev, newEngine]);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setEngines(prev => prev.filter(e => e.id !== id));
message.success('已删除');
};
const openEdit = (engine?: VideoEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
form.setFieldsValue(engine);
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0, maxDuration: 60,
supportedRatios: ['16:9', '9:16', '1:1'],
supportedResolutions: ['720p', '1080p'],
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: VideoEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PlayCircleOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
</div>
</div>
),
},
{
title: 'API地址', dataIndex: 'apiBase', width: 250,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>{v}</Typography.Text>,
},
{
title: '支持比例', dataIndex: 'supportedRatios', width: 180,
render: (ratios: string[]) => ratios.map(r => <Tag key={r}>{r}</Tag>),
},
{
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
render: (res: string[]) => res.map(r => <Tag key={r} color="blue">{r}</Tag>),
},
{
title: '最大时长', dataIndex: 'maxDuration', width: 80,
render: (v: number) => `${v}s`,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: VideoEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{engines.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><PlayCircleOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="Seedance 2.0" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'seedance', label: 'Seedance (火山引擎)' },
{ value: 'kling', label: 'Kling (快手)' },
{ value: 'runway', label: 'Runway' },
{ value: 'pika', label: 'Pika' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="模型名称">
<Input placeholder="seedance-2.0" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="supportedRatios" label="支持比例" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '16:9' }, { value: '9:16' }, { value: '1:1' }, { value: '4:3' },
]} />
</Form.Item>
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '720p' }, { value: '1080p' }, { value: '4K' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
<InputNumber min={5} max={300} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminVideoEngines;