Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+13
-14
@@ -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>
|
||||
|
||||
Generated
+4
-3
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
{"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 ###
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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="更新时间")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" 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;
|
||||
Reference in New Issue
Block a user