This commit is contained in:
2026-07-06 13:39:08 +08:00
parent 16b3d223c1
commit c68215c177
33 changed files with 3250 additions and 732 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-kmFIWx83.js"></script>
<script type="module" crossorigin src="/assets/index-OStxKvM2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+6
View File
@@ -34,6 +34,9 @@ import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
import AdminOAuthList from './pages/AdminOAuthList';
import AdminMaterialList from './pages/AdminMaterialList';
import { useAdminStore } from './store';
@@ -109,6 +112,9 @@ const App = () => {
<Route path="platform" element={<AdminPlatform />} />
<Route path="contact-requests" element={<AdminContactRequests />} />
<Route path="home-materials" element={<AdminHomeMaterials />} />
<Route path="pretest-templates" element={<AdminPreTestTemplates />} />
<Route path="oauth-list" element={<AdminOAuthList />} />
<Route path="material-list" element={<AdminMaterialList />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+53 -1
View File
@@ -500,7 +500,7 @@ export async function createOauthApp(data: {
app_id: string;
secret: string;
open_type: number;
count?: number;
max_count?: number;
auth_url?: string;
company?: string;
}): Promise<any> {
@@ -945,3 +945,55 @@ export async function regenerateHomeMaterialWatermark(id: string, payload: HomeM
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
await api.delete(`/admin/home-material/assets/${id}`);
}
export interface OAuthAccountParams {
id?: string;
phone?: string;
account_id?: string;
account_userid?: string;
created_at?: [string, string];
appid?: string;
open_type?: number;
page?: number;
page_size?: number;
}
export async function getOAuthAccountList(params: OAuthAccountParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.account_id) query.set('account_id', params.account_id);
if (params.account_userid) query.set('account_userid', params.account_userid);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.appid) query.set('appid', params.appid);
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/oauth-list?${query.toString()}`);
}
export interface MaterialListParams {
id?: string;
phone?: string;
resource_type?: string;
advertiser_id?: string;
material_id?: string;
upload_id?: string;
created_at?: [string, string];
status?: string;
page?: number;
page_size?: number;
}
export async function getMaterialList(params: MaterialListParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.resource_type) query.set('resource_type', params.resource_type);
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
if (params.material_id) query.set('material_id', params.material_id);
if (params.upload_id) query.set('upload_id', params.upload_id);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.status) query.set('status', params.status);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/material-list?${query.toString()}`);
}
@@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
import { getMaterialList } from '../api';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const { Text } = Typography;
const AdminMaterialList: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchMaterialId, setSearchMaterialId] = useState('');
const [searchUploadId, setSearchUploadId] = useState('');
const [searchResourceType, setSearchResourceType] = useState('');
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '广告主账户ID',
dataIndex: 'advertiserId',
key: 'advertiserId',
},
{
title: '备注',
dataIndex: 'note',
key: 'note',
},
{
title: '授权ID',
dataIndex: 'oauthId',
key: 'oauthId',
},
{
title: '预测试结果',
dataIndex: 'preResult',
key: 'preResult',
},
{
title: '预测试模板ID',
dataIndex: 'preTestTemplateId',
key: 'preTestTemplateId',
},
{
title: '目标标ID',
dataIndex: 'targetId',
key: 'targetId',
},
{
title: '目标表',
dataIndex: 'targetTable',
key: 'targetTable',
},
{
title: '任务ID',
dataIndex: 'taskId',
key: 'taskId',
},
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
},
{
title: '素材ID',
dataIndex: 'materialId',
key: 'materialId',
width: 180,
},
{
title: '上传ID',
dataIndex: 'uploadId',
key: 'uploadId',
width: 160,
},
{
title: '资源类型',
dataIndex: 'resourceType',
key: 'resourceType',
width: 100,
render: (text: string) => (
<Tag color={text === 'video' ? 'blue' : 'green'}>
{text === 'video' ? '视频' : '图片'}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (text: string) => (
<Tag color={text === 'SUCCESS' ? 'green' : text === 'PENDING' ? 'orange' : 'red'}>
{text === 'SUCCESS' ? '成功' : text === 'PENDING' ? '处理中' : '失败'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
},
];
const loadMaterialList = async () => {
setLoading(true);
try {
const res = await getMaterialList({
material_id: searchMaterialId || undefined,
upload_id: searchUploadId || undefined,
resource_type: searchResourceType || undefined,
page: currentPage,
page_size: pageSize,
});
const data = res.items || [];
const tableData = data.map((item: any, index: number) => ({
...item,
index: (currentPage - 1) * pageSize + index + 1,
}));
setTableData(tableData);
setTotal(res.total || 0);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMaterialList();
}, [currentPage, pageSize]);
const handleSearch = () => {
setCurrentPage(1);
loadMaterialList();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}>ID列表</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input
placeholder="素材ID"
value={searchMaterialId}
onChange={(e) => setSearchMaterialId(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<Input
placeholder="上传ID"
value={searchUploadId}
onChange={(e) => setSearchUploadId(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<Input
placeholder="文件名"
value={searchResourceType}
onChange={(e) => setSearchResourceType(e.target.value)}
style={{ width: 140 }}
allowClear
onPressEnter={handleSearch}
/>
<Select
placeholder="资源类型"
value={searchResourceType}
onChange={(value) => setSearchResourceType(value)}
style={{ width: 120 }}
allowClear
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
}}
size="small"
/>
</div>
</div>
</div>
);
};
export default AdminMaterialList;
@@ -0,0 +1,284 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
import { LockOutlined, SearchOutlined } from '@ant-design/icons';
import { getOAuthAccountList, getOpenTypeAll } from '../api';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const { Text } = Typography;
const AdminOAuthList: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchAccountId, setSearchAccountId] = useState('');
const [searchUserId, setSearchUserId] = useState('');
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
const [searchOpenType, setSearchOpenType] = useState<number | undefined>(undefined);
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '前台用户ID',
dataIndex: 'userId',
key: 'userId',
},
{
title: '前台用户手机号',
dataIndex: 'userPhone',
key: 'userPhone',
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
},
{
title: '授权绑定账户ID',
dataIndex: 'accountId',
key: 'accountId',
},
{
title: '授权绑定账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权绑定账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权登录账户用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
},
{
title: '授权登录账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
width: 120,
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
},
{
title: '平台端口',
dataIndex: 'portType',
key: 'portType',
render: (role: string) => {
const roleMap: Record<string, string> = {
1: '巨量',
2: '磁力',
3: '巨量星图',
4: '服务单',
5: '腾讯',
};
return roleMap[role] || role;
},
// 1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯
// render: (text: number) => <span style={{ color: '#1e293b' }}>{text}</span>,
},
{
title: '授权刷新Token',
dataIndex: 'refreshToken',
key: 'refreshToken',
},
{
title: '刷新Token过期时间',
dataIndex: 'refreshTokenExpired',
key: 'refreshTokenExpired',
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '授权Token',
dataIndex: 'accessToken',
key: 'accessToken',
},
{
title: 'Token过期时间',
dataIndex: 'accessTokenExpired',
key: 'accessTokenExpired',
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
];
const loadOpenTypeList = async () => {
try {
const res = await getOpenTypeAll();
const data = res.data || [];
const map: Record<number, string> = {};
const options: { value: number; label: string }[] = [];
data.forEach(item => {
map[item.openType] = item.typeName;
options.push({ value: item.openType, label: item.typeName });
});
setOpenTypeMap(map);
setOpenTypeOptions(options);
} catch (error) {
console.error('加载开户方式列表失败:', error);
}
};
const loadOAuthList = async () => {
setLoading(true);
try {
const res = await getOAuthAccountList({
account_id: searchAccountId || undefined,
account_userid: searchUserId || undefined,
open_type: searchOpenType,
page: currentPage,
page_size: pageSize,
});
const data = res.data || [];
const tableData = data.map((item: any, index: number) => ({
...item,
index: (currentPage - 1) * pageSize + index + 1,
}));
setTableData(tableData);
setTotal(res.pagination?.total || 0);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOpenTypeList();
loadOAuthList();
}, [currentPage, pageSize]);
const handleSearch = () => {
setCurrentPage(1);
loadOAuthList();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<LockOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}></Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input
placeholder="授权绑定账户ID"
value={searchAccountId}
onChange={(e) => setSearchAccountId(e.target.value)}
style={{ width: 180 }}
allowClear
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
/>
<Input
placeholder="授权登录账户用户ID"
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
style={{ width: 180 }}
allowClear
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
/>
<Select
placeholder="开户方式"
value={searchOpenType}
onChange={(value) => setSearchOpenType(value)}
style={{ width: 140 }}
allowClear
options={openTypeOptions}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
}
}}
/>
</div>
</div>
);
};
export default AdminOAuthList;
+24 -24
View File
@@ -10,16 +10,16 @@ import { formatDate } from '../utils/formatDate';
interface OAuthApp {
id: string;
app_id: string;
appId: string;
secret: string;
status: number;
max_count: number;
open_type: number;
auth_url?: string;
maxCount: number;
openType: number;
authUrl?: string;
company?: string;
create_by: string;
created_at: string;
updated_at: string;
createBy: string;
createdAt: string;
updatedAt: string;
}
const AdminOauthAppList: React.FC = () => {
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
app_id: values.app_id,
secret: values.secret,
open_type: values.open_type,
count: values.count,
max_count: values.max_count,
auth_url: values.auth_url,
company: values.company,
});
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
const app = await getOauthApp(id);
setCurrentApp(app);
updateForm.setFieldsValue({
app_id: app.app_id,
appId: app.appId,
secret: app.secret,
open_type: app.open_type,
count: app.max_count,
auth_url: app.auth_url,
openType: app.openType,
maxCount: app.maxCount,
authUrl: app.authUrl,
company: app.company,
});
setUpdateModalVisible(true);
@@ -298,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
</Select>
</Form.Item>
<Form.Item
name="count"
name="max_count"
label="最大授权用户数"
initialValue={100}
>
@@ -334,22 +334,22 @@ const AdminOauthAppList: React.FC = () => {
{currentApp && (
<div style={{ lineHeight: '2' }}>
<p><strong>ID:</strong> {currentApp.id}</p>
<p><strong>ID:</strong> {currentApp.app_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.open_type] || currentApp.open_type;
return typeMap[currentApp.openType] || currentApp.openType;
})()}</p>
<p><strong>:</strong> {currentApp.company || '-'}</p>
<p><strong>:</strong> {currentApp.max_count}</p>
<p><strong>:</strong> {currentApp.maxCount}</p>
<p><strong>:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
<p><strong>URL:</strong> {currentApp.auth_url || '-'}</p>
<p><strong>:</strong> {currentApp.create_by}</p>
<p><strong>:</strong> {formatDate(currentApp.created_at)}</p>
<p><strong>:</strong> {formatDate(currentApp.updated_at)}</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>
@@ -369,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
>
<Form form={updateForm} layout="vertical">
<Form.Item
name="app_id"
name="appId"
label="应用ID"
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
>
@@ -383,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
<Input placeholder="请输入应用密钥" />
</Form.Item>
<Form.Item
name="open_type"
name="openType"
label="开户方式"
rules={[{ required: true, message: '请选择开户方式' }]}
>
@@ -401,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
</Select>
</Form.Item>
<Form.Item
name="count"
name="maxCount"
label="最大授权用户数"
>
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
</Form.Item>
<Form.Item
name="auth_url"
name="authUrl"
label="应用授权链接"
>
<Input placeholder="请输入应用授权链接" />
@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
const { Text } = Typography;
const AdminPreTestTemplates: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchPlatform, setSearchPlatform] = useState('');
const [searchName, setSearchName] = useState('');
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: '模板名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '投放平台',
dataIndex: 'platform',
key: 'platform',
width: 120,
render: (text: string) => (
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'}>
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (text: string) => (
<Tag color={text === 'active' ? 'green' : 'red'}>
{text === 'active' ? '启用' : '禁用'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
},
];
const loadRecords = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('page_size', String(pageSize));
if (searchPlatform) params.set('platform', searchPlatform);
if (searchName) params.set('name', searchName);
const mockData = {
items: Array.from({ length: pageSize }, (_, i) => ({
id: `${currentPage}-${i}`,
index: (currentPage - 1) * pageSize + i + 1,
name: `前测模板${(currentPage - 1) * pageSize + i + 1}`,
platform: ['AD', 'QIANCHUAN', 'LOCAL'][i % 3],
status: i % 5 === 0 ? 'inactive' : 'active',
createdAt: '2026-07-01 10:00:00',
updatedAt: '2026-07-02 14:30:00',
})),
total: 50,
};
setTableData(mockData.items);
setTotal(mockData.total);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRecords();
}, [currentPage, pageSize]);
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
};
const handleSearch = () => {
setCurrentPage(1);
loadRecords();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}></Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Input
placeholder="模板名称"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
style={{ width: 180 }}
allowClear
onPressEnter={handleSearch}
/>
<Select
placeholder="投放平台"
value={searchPlatform}
onChange={(value) => setSearchPlatform(value)}
style={{ width: 140 }}
allowClear
options={[
{ value: 'AD', label: 'AD' },
{ value: 'QIANCHUAN', label: '千川' },
{ value: 'LOCAL', label: '本地推' },
]}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{
background: '#fff',
borderRadius: 16,
boxShadow: '0 4px 20px rgba(0,0,0,0.04)',
overflow: 'hidden',
}}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
onRow={() => ({
style: {
cursor: 'pointer',
transition: 'background-color 0.15s',
},
onMouseEnter: (e: React.MouseEvent) => {
(e.currentTarget as HTMLElement).style.backgroundColor = '#f8fafc';
},
onMouseLeave: (e: React.MouseEvent) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
},
})}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={handlePageChange}
size="small"
/>
</div>
</div>
</div>
);
};
export default AdminPreTestTemplates;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.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/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.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/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+2
View File
@@ -31,6 +31,7 @@ from app.api.v1.contact import router as contact_router
from app.api.v1.team import router as team_router
from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -64,3 +65,4 @@ api_router.include_router(contact_router)
api_router.include_router(team_router)
api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router)
+168
View File
@@ -0,0 +1,168 @@
from typing import Any, Optional, List
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Depends, Body
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.resources_material import ResourcesMaterial
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.pre_test_template import PreTestTemplate
from app.models.upload_task import UploadTask
from app.dependencies import get_current_user, get_db, get_admin_user
from app.services.material_consumption_queue import sync_all_advertisers_consumption, _fetch_and_save_consumption
from app.services.material_consumption_service import get_consumption_list, format_consumption_response
from app.services.material_admin_service import get_oauth_list, get_material_list, get_pre_test_template_list, get_upload_task_list
router = APIRouter(prefix="/material-admin", tags=["material-admin"])
@router.get("/oauth-list", summary="管理员查看所有授权列表")
async def admin_get_oauth_list(
id: Optional[str] = Query(None, description="主键id"),
phone: Optional[str] = Query(None, description="用户登录手机号"),
account_id: Optional[str] = Query(None, description="授权账户id"),
account_userid: Optional[str] = Query(None, description="授权登录账号id"),
created_at: Optional[List[datetime]] = Query(None, description="授权时间"),
appid: Optional[str] = Query(None, description="应用id"),
open_type: Optional[int] = Query(None, description="开户方式"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
try:
oauth_list, total = await get_oauth_list(
id=id,
db=db,
phone=phone,
account_id=account_id,
account_userid=account_userid,
created_at=created_at,
appid=appid,
open_type=open_type,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": oauth_list,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
except Exception as e:
return {
"code": 1,
"msg": str(e),
"data": None,
}
@router.get("/material-list", summary="管理员查看所有素材资源列表")
async def admin_get_material_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
resource_type: Optional[str] = Query(None, description="素材资源类型"),
advertiser_id: Optional[str] = Query(None, description="广告主id"),
material_id: Optional[str] = Query(None, description="素材id"),
upload_id: Optional[str] = Query(None, description="平台id【视频id/图片id】"),
created_at: Optional[List[datetime]] = Query(None, description="创建时间"),
status: Optional[str] = Query(None, description="前测状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
materials, total = await get_material_list(
db=db,
phone=phone,
id=id,
resource_type=resource_type,
advertiser_id=advertiser_id,
material_id=material_id,
upload_id=upload_id,
created_at=created_at,
status=status,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": materials,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@router.get("/pre-test-template-list", summary="管理员查看所有前测模板列表")
async def admin_get_pre_test_template_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
created_at: Optional[datetime] = Query(None, description="创建时间"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
templates, total = await get_pre_test_template_list(
db=db,
phone=phone,
id=id,
created_at=created_at,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": templates,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@router.get("/upload-task-list", summary="管理员查看推送任务列表")
async def admin_get_upload_task_list(
phone: Optional[str] = Query(None, description="用户登录手机号"),
id: Optional[str] = Query(None, description="主键id"),
created_at: Optional[List[str]] = Query(None, description="创建时间范围,示例: ['2023-01-01', '2023-03-01']"),
advertiser_id: Optional[str] = Query(None, description="广告主id"),
status: Optional[int] = Query(None, description="前测状态"),
resource_id: Optional[str] = Query(None, description="资源id"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
) -> Any | dict:
tasks, total = await get_upload_task_list(
db=db,
phone=phone,
id=id,
created_at=created_at,
advertiser_id=advertiser_id,
status=status,
resource_id=resource_id,
page=page,
page_size=page_size,
)
return {
"code": 0,
"data": tasks,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
+1 -1
View File
@@ -56,7 +56,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"request_body": encrypt_data(request_body) if request_body else "",
"request_body": encrypt_data(request_body, True) if request_body else "",
"status": response.status_code,
"duration_ms": duration_ms,
"ip": request.client.host if request.client else "-",
+1 -1
View File
@@ -159,4 +159,4 @@ def extract_error_message(exc: Exception, service_type: str = "video") -> str:
return f"{service_type}生成失败: {code}"
except (json.JSONDecodeError, TypeError, ValueError):
pass
return raw[:200] if len(raw) > 200 else raw
return raw[:5000] if len(raw) > 5000 else raw
+2 -2
View File
@@ -30,7 +30,7 @@ def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_str = json.dumps(request_data, ensure_ascii=False)
request_encrypted = encrypt_data(request_data)
request_encrypted = encrypt_data(request_data, True)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "image_gen_request",
@@ -54,7 +54,7 @@ def _log_image_response(record_id: str, response_data: dict, error: str | None =
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
response_encrypted = encrypt_data(response_data) if response_data else ""
response_encrypted = encrypt_data(response_data, True) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "image_gen_response",
+2 -2
View File
@@ -37,8 +37,8 @@ def _log_ai_request_response(config, request_data: dict, response_data: dict | N
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_encrypted = encrypt_data(_sanitize_for_log(request_data))
response_encrypted = encrypt_data(_sanitize_for_log(response_data)) if response_data else ""
request_encrypted = encrypt_data(_sanitize_for_log(request_data), True)
response_encrypted = encrypt_data(_sanitize_for_log(response_data), True) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"model_name": config.name,
+4 -1
View File
@@ -18,8 +18,11 @@ ENCRYPTION_KEY = b'videogen@202605!'
def encrypt_data(data: dict) -> str:
def encrypt_data(data: dict, is_encrypt: bool = False) -> str:
data_str = json.dumps(data, ensure_ascii=False, sort_keys=True)
if is_encrypt:
return data_str
data_bytes = data_str.encode("utf-8")
padder = padding.PKCS7(128).padder()
@@ -0,0 +1,379 @@
from typing import Optional, List, Tuple
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.resources_material import ResourcesMaterial
from app.models.pre_test_template import PreTestTemplate
from app.models.upload_task import UploadTask
from app.utils.datetime_util import BEIJING_TZ, datetime_to_db_tz_str, db_tz_str_to_datetime, parse_date_range
async def get_oauth_list(
id: Optional[str] = None,
db: AsyncSession = None,
phone: Optional[str] = None,
account_id: Optional[str] = None,
account_userid: Optional[str] = None,
created_at: Optional[datetime] = None,
appid: Optional[str] = None,
open_type: Optional[int] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[UserOAuth], int]:
offset = (page - 1) * page_size
query = select(UserOAuth).where(UserOAuth.deleted_at.is_(None))
if id:
query = query.where(UserOAuth.id == id)
if phone:
query = query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if account_id:
query = query.where(UserOAuth.account_id == account_id)
if account_userid:
query = query.where(UserOAuth.account_userid == account_userid)
# 【改动3】适配入参 created_at=["2025-01-01","2026-01-01"] 字符串日期场景
if created_at and len(created_at) == 2:
try:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
UserOAuth.created_at >= start_dt,
UserOAuth.created_at <= end_dt
)
except ValueError:
# 捕获日期格式错误,非法时间直接不附加该查询条件
pass
if appid:
query = query.where(UserOAuth.appid == appid)
if open_type:
query = query.where(UserOAuth.open_type == open_type)
count_query = select(func.count(UserOAuth.id)).where(UserOAuth.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if account_id:
count_query = count_query.where(UserOAuth.account_id == account_id)
if account_userid:
count_query = count_query.where(UserOAuth.account_userid == account_userid)
if created_at:
try:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
count_query = count_query.where(
UserOAuth.created_at >= start_dt,
UserOAuth.created_at <= end_dt
)
except ValueError:
# 捕获日期格式错误,非法时间直接不附加该查询条件
pass
if appid:
count_query = count_query.where(UserOAuth.appid == appid)
if open_type:
count_query = count_query.where(UserOAuth.open_type == open_type)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(UserOAuth.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = [];
for oauth in result.scalars().all():
user_query = select(User).where(User.id == oauth.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": oauth.id,
"account_id": oauth.account_id,
"account_name": oauth.account_name,
"account_role": oauth.account_role,
"account_username": oauth.account_username,
"account_userid": oauth.account_userid,
"user_id": oauth.user_id,
"user_phone": phone,
"open_type": oauth.open_type,
"port_type": oauth.port_type,
"appid": oauth.appid,
"access_token": oauth.access_token,
"refresh_token": oauth.refresh_token,
"access_token_expired": oauth.access_token_expired,
"refresh_token_expired": oauth.refresh_token_expired,
"created_at": oauth.created_at,
"updated_at": oauth.updated_at,
}
list.append(item)
return list, total
async def get_material_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
resource_type: Optional[str] = None,
advertiser_id: Optional[str] = None,
material_id: Optional[str] = None,
upload_id: Optional[str] = None,
created_at: Optional[datetime] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[ResourcesMaterial], int]:
offset = (page - 1) * page_size
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
if phone:
query = query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if id:
query = query.where(ResourcesMaterial.id == id)
if resource_type:
query = query.where(ResourcesMaterial.resource_type == resource_type)
if advertiser_id:
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id:
query = query.where(ResourcesMaterial.material_id == material_id)
if upload_id:
query = query.where(ResourcesMaterial.upload_id == upload_id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
ResourcesMaterial.created_at >= start_dt,
ResourcesMaterial.created_at <= end_dt
)
if status:
query = query.where(ResourcesMaterial.status == status)
count_query = select(func.count(ResourcesMaterial.id)).where(ResourcesMaterial.deleted_at.is_(None))
if phone:
count_query = count_query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(ResourcesMaterial.id == id)
if resource_type:
count_query = count_query.where(ResourcesMaterial.resource_type == resource_type)
if advertiser_id:
count_query = count_query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id:
count_query = count_query.where(ResourcesMaterial.material_id == material_id)
if upload_id:
count_query = count_query.where(ResourcesMaterial.upload_id == upload_id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
count_query = count_query.where(
ResourcesMaterial.created_at >= start_dt,
ResourcesMaterial.created_at <= end_dt
)
if status:
count_query = count_query.where(ResourcesMaterial.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(ResourcesMaterial.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"oauth_id": item.oauth_id,
"user_phone": phone,
"advertiser_id": item.advertiser_id,
"target_table": item.target_table,
"target_id": item.target_id,
"material_id": item.material_id,
"upload_id": item.upload_id,
"resource_type": item.resource_type,
"user_id": item.user_id,
"created_at": item.created_at,
"updated_at": item.updated_at,
"task_id": item.task_id,
"note": item.note,
"status": item.status,
"pre_result": item.pre_result,
"pre_test_template_id": item.pre_test_template_id,
}
list.append(item)
return list, total
async def get_pre_test_template_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
created_at: Optional[datetime] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[PreTestTemplate], int]:
offset = (page - 1) * page_size
query = select(PreTestTemplate).where(PreTestTemplate.deleted_at.is_(None))
if phone:
query = query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
if id:
query = query.where(PreTestTemplate.id == id)
if created_at:
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
query = query.where(
PreTestTemplate.created_at >= start_dt,
PreTestTemplate.created_at <= end_dt
)
count_query = select(func.count(PreTestTemplate.id)).where(PreTestTemplate.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(PreTestTemplate.id == id)
if created_at:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
count_query = count_query.where(
PreTestTemplate.created_at >= start_dt,
PreTestTemplate.created_at <= end_dt
)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(PreTestTemplate.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"name": item.name,
"user_phone": phone,
"user_id": item.user_id,
"platform": item.platform,
"external_action": item.external_action,
"cpa_bid": item.cpa_bid,
"audience_gender": item.audience_gender,
"audience_age": item.audience_age,
"audience_region": item.audience_region,
"audience_network": item.audience_network,
"cus_name": item.cus_name,
"pricing_type": item.pricing_type,
"cost_cap": item.cost_cap,
"target_cost": item.target_cost,
"nobid": item.nobid,
"cpc_bid": item.cpc_bid,
"budget": item.budget,
"is_default": item.is_default,
"created_at": item.created_at,
"updated_at": item.updated_at,
"note": item.note,
}
list.append(item)
return list, total
async def get_upload_task_list(
db: AsyncSession,
phone: Optional[str] = None,
id: Optional[str] = None,
created_at: Optional[List[str]] = None,
advertiser_id: Optional[str] = None,
status: Optional[int] = None,
resource_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> Tuple[List[UploadTask], int]:
offset = (page - 1) * page_size
query = select(UploadTask).where(UploadTask.deleted_at.is_(None))
if phone:
query = query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
if id:
query = query.where(UploadTask.id == id)
if created_at and len(created_at) >= 2:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
query = query.where(
UploadTask.created_at >= start_dt,
UploadTask.created_at <= end_dt
)
if advertiser_id:
query = query.where(UploadTask.advertiser_id == advertiser_id)
if status:
query = query.where(UploadTask.status == status)
if resource_id:
query = query.where(UploadTask.resource_id == resource_id)
count_query = select(func.count(UploadTask.id)).where(UploadTask.deleted_at.is_(None))
if phone:
count_query = count_query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
if id:
count_query = count_query.where(UploadTask.id == id)
if created_at and len(created_at) >= 2:
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
count_query = count_query.where(
UploadTask.created_at >= start_dt,
UploadTask.created_at <= end_dt
)
if advertiser_id:
count_query = count_query.where(UploadTask.advertiser_id == advertiser_id)
if status:
count_query = count_query.where(UploadTask.status == status)
if resource_id:
count_query = count_query.where(UploadTask.resource_id == resource_id)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
query = query.order_by(UploadTask.id.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
list = []
for item in result.scalars().all():
user_query = select(User).where(User.id == item.user_id)
user_result = await db.execute(user_query)
user = user_result.scalar_one()
if user:
phone = user.phone
else:
phone = None
item = {
"id": item.id,
"user_phone": phone,
"user_id": item.user_id,
"advertiser_id": item.advertiser_id,
"resource_id": item.resource_id,
"status": item.status,
"note": item.note,
"oauth_id": item.oauth_id,
"created_at": item.created_at,
"updated_at": item.updated_at,
"other_info": item.other_info,
}
list.append(item)
return list, total
+203 -198
View File
@@ -1,20 +1,22 @@
import random
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
import httpx
from sqlalchemy import select, func
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
# 引入项目统一北京时间时区
from app.utils.datetime_util import BEIJING_TZ
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.utils.id_gen import generate_id
from app.tasks.token_refresh_task import _update_redis_token
from app.tasks.token_refresh_task import _update_redis_token, _delete_redis_token
#随机获取一个可用的应用配置
# 随机获取一个可用的应用配置
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
# 从 user_oauth_app 表查询可用应用
# 过滤条件:open_type 匹配、status=1(正常)、deleted_at is None
if not isinstance(open_type, int):
raise ValueError("open_type必须为整数类型")
result = await db.execute(
select(UserOAuthApp).where(
UserOAuthApp.open_type == open_type,
@@ -30,8 +32,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
available_apps = []
for app in apps:
app_id = app.app_id
# 查询该应用当前授权数量
count_result = await db.execute(
select(func.count(UserOAuth.id)).where(
UserOAuth.appid == app_id,
@@ -39,8 +39,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
)
)
count = count_result.scalar() or 0
# 检查是否达到最大授权数
max_users = app.max_count
if count < max_users:
available_apps.append({
@@ -59,24 +57,21 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
async def build_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
if open_type == 1:
#千川
return await _build_jl_oauth_url(open_type, user_id, db)
elif open_type in [2, 3]:
#广告,本地推
return await _build_jl_oauth_url(2, user_id, db)
elif open_type == 5:
#快手代理商
return "无配置"
elif open_type == 9:
#腾讯营销K2
return "无配置"
elif open_type == 10:
#腾讯营销K3
return "无配置"
else:
raise ValueError(f"不支持的应用类型: {app_type}")
# 修复BUG:变量名错误 app_type -> open_type
raise ValueError(f"不支持的应用类型: {open_type}")
#千川授权链接构建
# 千川授权链接构建
async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
app = await get_available_app(open_type, db)
app_id = app.get("app_id")
@@ -92,221 +87,232 @@ async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) ->
async def get_token(code: str, user_id: str, app_id: str, db: AsyncSession) -> dict:
#1.根据app_id查询应用配置
if not all([code, user_id, app_id]):
raise ValueError("code、user_id、app_id不能为空")
result = await db.execute(
select(UserOAuthApp).where(UserOAuthApp.app_id == app_id)
select(UserOAuthApp).where(
UserOAuthApp.app_id == app_id,
UserOAuthApp.deleted_at.is_(None),
UserOAuthApp.status == 1
)
)
app = result.scalar_one_or_none()
if not app:
raise ValueError("应用配置不存在")
raise ValueError("应用配置不存在或已禁用")
open_type = app.open_type
secret = app.secret
if open_type == 1 or open_type == 2:
#千川或广告
if open_type in (1, 2):
return await get_juliang_token(app_id, secret, code, open_type, user_id, db)
elif open_type == 5:
#快手代理商
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
elif open_type == 9 or open_type == 10:
#腾讯营销K2或腾讯营销K3
elif open_type in (9, 10):
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
else:
raise ValueError(f"不支持的应用类型: {open_type}")
async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int, user_id: str, db: AsyncSession) -> dict:
timeout = httpx.Timeout(30.0)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
# 1. 获取token
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
resp = await client.post(
url,
json={
"app_id": app_id,
"secret": secret,
"auth_code": code,
},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
msg = data.get("message", "获取token失败")
raise ValueError(f"获取token失败:{msg}")
async with httpx.AsyncClient() as client:
#1.请求token
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
response = await client.post(
url,
json={
"app_id": app_id,
"secret": secret,
"auth_code": code,
},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取token失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
access_token = data.get("access_token", "")
refresh_token = data.get("refresh_token", "")
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
#2.获取已授权角色账户,一个授权可能有多个角色账户
url = "https://api.oceanengine.com/open_api/oauth2/advertiser/get/"
response = await client.get(
url,
headers={"Access-Token": access_token},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取已授权账户失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
account_list = data.get("list", [])
#3.获取已授权登录信息
url = "https://api.oceanengine.com/open_api/2/user/info/"
response = await client.get(
url,
headers={"Access-Token": access_token},
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise ValueError(data.get("message", "获取已授权登录信息失败")+f",错误信息:{data.get('message', '')}")
data = data.get("data", {})
account_username = data.get("email", "")
account_userid = str(data.get("id", ""))
material_auth_status = data.get("material_auth_status", False)
#4.根据登录信息判断是否新增token或更新token,不同的登录信息对应不同的token,然后更新数据库
#查询emailappiduser_id是否存在已授权记录
oauth = await db.execute(
select(UserOAuth)
.where(UserOAuth.account_username == account_username, UserOAuth.account_userid == account_userid, UserOAuth.appid == app_id, UserOAuth.user_id == user_id)
.limit(1)
)
oauth = oauth.scalar_one_or_none()
if not oauth:
new_oauth_ids = []
for account in account_list:
oauth_id = generate_id()
new_oauth_ids.append(oauth_id)
#新增授权记录
db.add(UserOAuth(
id=oauth_id,
account_id = str(account.get("account_id", "")),
account_name = account.get("account_name", ""),
account_role = account.get("account_role", ""),
account_username = account_username,
account_userid = account_userid,
user_id = user_id,
appid = app_id,
open_type = open_type,
port_type = 1,
access_token = access_token,
access_token_expired = expires_in,
refresh_token = refresh_token,
refresh_token_expired = refresh_token_expires_in,
material_auth_status = material_auth_status,
))
await db.commit()
resp_data = data.get("data", {})
access_token = resp_data.get("access_token", "")
refresh_token = resp_data.get("refresh_token", "")
expires_sec = resp_data.get("expires_in", 7200)
refresh_expires_sec = resp_data.get("refresh_token_expires_in", 30 * 24 * 3600)
# 【修复:统一使用北京时间计算过期时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
access_expired = now_beijing + timedelta(seconds=expires_sec)
refresh_expired = now_beijing + timedelta(seconds=refresh_expires_sec)
for oauth_id in new_oauth_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
else:
# 查询现有授权记录(未删除的)
existing_accounts = await db.execute(
select(UserOAuth).where(
# 2. 获取授权广告账户列表
resp = await client.get(
"https://api.oceanengine.com/open_api/oauth2/advertiser/get/",
headers={"Access-Token": access_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ValueError(f"获取已授权账户失败:{data.get('message')}")
account_list = data.get("data", {}).get("list", [])
# 3. 获取登录用户信息
resp = await client.get(
"https://api.oceanengine.com/open_api/2/user/info/",
headers={"Access-Token": access_token},
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise ValueError(f"获取登录信息失败:{data.get('message')}")
user_info = data.get("data", {})
account_username = user_info.get("email", "")
account_userid = str(user_info.get("id", ""))
material_auth_status = user_info.get("material_auth_status", False)
# 4. 查询当前用户+应用+登录账号下的授权记录
oauth_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None)
)
oauth_exist = await db.execute(oauth_query.limit(1))
oauth_exist = oauth_exist.scalar_one_or_none()
if not oauth_exist:
# 新增授权
new_oauth_ids = []
for account in account_list:
oauth_id = generate_id()
new_oauth_ids.append(oauth_id)
db.add(UserOAuth(
id=oauth_id,
account_id=str(account.get("account_id", "")),
account_name=account.get("account_name", ""),
account_role=account.get("account_role", ""),
account_username=account_username,
account_userid=account_userid,
user_id=user_id,
appid=app_id,
open_type=open_type,
port_type=1,
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
))
await db.commit()
# 批量写入Redis
for oauth_id in new_oauth_ids:
await _update_redis_token(oauth_id, access_token, access_expired)
else:
# 查询当前所有有效授权账户
exist_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None),
)
)
existing_accounts = {acc.account_id: acc for acc in existing_accounts.scalars().all()}
exist_res = await db.execute(exist_query)
exist_map = {item.account_id: item for item in exist_res.scalars().all()}
# 新的账户列表
new_account_ids = {str(account.get("account_id")) for account in account_list}
old_account_ids = set(existing_accounts.keys())
new_account_ids = {str(acc.get("account_id")) for acc in account_list}
old_account_ids = set(exist_map.keys())
update_redis_ids = []
# 需要更新Redis的oauth_id列表
update_redis_ids = []
# 下线的账户:软删除 + 清理Redis脏缓存
for del_account_id in old_account_ids - new_account_ids:
del_oauth = exist_map[del_account_id]
del_oauth.deleted_at = now_beijing
await _delete_redis_token(del_oauth.id)
# 1. 软删除已消失的账户
for account_id in old_account_ids - new_account_ids:
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
# 2. 更新或新增账户
for account in account_list:
account_id = str(account.get("account_id", ""))
if account_id in existing_accounts:
# 更新现有记录
existing_oauth = existing_accounts[account_id]
existing_oauth.account_name = account.get("account_name", "")
existing_oauth.account_role = account.get("account_role", "")
existing_oauth.access_token = access_token
existing_oauth.access_token_expired = expires_in
existing_oauth.refresh_token = refresh_token
existing_oauth.refresh_token_expired = refresh_token_expires_in
existing_oauth.material_auth_status = material_auth_status
update_redis_ids.append(existing_oauth.id)
else:
# 新增记录
oauth_id = generate_id()
update_redis_ids.append(oauth_id)
db.add(UserOAuth(
# 更新/新增当前授权账户
for account in account_list:
aid = str(account.get("account_id", ""))
if aid in exist_map:
item = exist_map[aid]
item.account_name = account.get("account_name", "")
item.account_role = account.get("account_role", "")
item.access_token = access_token
item.access_token_expired = access_expired
item.refresh_token = refresh_token
item.refresh_token_expired = refresh_expired
item.material_auth_status = material_auth_status
update_redis_ids.append(item.id)
else:
oauth_id = generate_id()
update_redis_ids.append(oauth_id)
db.add(UserOAuth(
id=oauth_id,
account_id=str(account_id),
account_id=aid,
account_name=account.get("account_name", ""),
account_role=account.get("account_role", ""),
account_username=account_username,
account_userid = account_userid,
account_userid=account_userid,
user_id=user_id,
appid=app_id,
open_type=open_type,
port_type=1,
access_token = access_token,
access_token_expired=expires_in,
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_token_expires_in,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
))
await db.commit()
await db.commit()
# 更新有效账号缓存
for oid in update_redis_ids:
await _update_redis_token(oid, access_token, access_expired)
for oauth_id in update_redis_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
from sqlalchemy import update
related_oauths = await db.execute(
select(UserOAuth).where(
# 5. 同登录账号、同应用、其他用户下的授权批量同步最新token
related_query = select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
)
)
related_oauths = related_oauths.scalars().all()
if related_oauths:
await db.execute(
update(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
).values(
access_token=access_token,
access_token_expired=expires_in,
refresh_token=refresh_token,
refresh_token_expired=refresh_token_expires_in,
material_auth_status=material_auth_status,
related_res = await db.execute(related_query)
related_list = related_res.scalars().all()
if related_list:
await db.execute(
update(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
).values(
access_token=access_token,
access_token_expired=access_expired,
refresh_token=refresh_token,
refresh_token_expired=refresh_expired,
material_auth_status=material_auth_status,
)
)
)
await db.commit()
await db.commit()
for item in related_list:
await _update_redis_token(item.id, access_token, access_expired)
for related_oauth in related_oauths:
await _update_redis_token(related_oauth.id, access_token, expires_in)
return {"message": "授权成功"}
except httpx.HTTPError as e:
raise ValueError(f"第三方接口请求异常:{str(e)}")
except Exception as e:
raise ValueError(f"授权处理异常:{str(e)}")
return {"message": "授权成功"}
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
return "未配置"
return {"message": "快手渠道暂未实现授权逻辑"}
async def get_tencent_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> str:
return "未配置"
return "腾讯营销渠道暂未实现授权逻辑"
async def get_oauth_list(
@@ -318,36 +324,35 @@ async def get_oauth_list(
page: int = 1,
page_size: int = 10,
) -> dict:
if page < 1:
page = 1
if page_size < 1:
page_size = 10
# 分页参数容错
page = max(page, 1)
page_size = max(min(page_size, 100), 1)
query = select(UserOAuth).where(
base_where = [
UserOAuth.user_id == user_id,
UserOAuth.deleted_at.is_(None),
)
]
if account_userid:
query = query.where(UserOAuth.account_userid == account_userid)
base_where.append(UserOAuth.account_userid == account_userid)
if open_type:
query = query.where(UserOAuth.open_type == open_type)
base_where.append(UserOAuth.open_type == open_type)
if account_id:
query = query.where(UserOAuth.account_id == account_id)
base_where.append(UserOAuth.account_id == account_id)
query = query.order_by(UserOAuth.created_at.desc())
# 统计总条数(优化:使用count,避免全量查询)
count_stmt = select(func.count(UserOAuth.id)).where(*base_where)
total = await db.scalar(count_stmt) or 0
total_result = await db.execute(query.with_only_columns(UserOAuth.id))
total = len(total_result.scalars().all())
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
oauth_list = result.scalars().all()
# 分页查询数据
data_stmt = select(UserOAuth).where(*base_where)\
.order_by(UserOAuth.created_at.desc())\
.offset((page - 1) * page_size)\
.limit(page_size)
result = await db.execute(data_stmt)
data_list = result.scalars().all()
return {
"data": oauth_list,
"data": data_list,
"total": total,
"page": page,
"page_size": page_size,
+2 -2
View File
@@ -30,7 +30,7 @@ def _log_video_request(engine: ProviderVideoEngineLike, record_id: str, request_
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
request_str = json.dumps(request_data, ensure_ascii=False)
request_encrypted = encrypt_data(request_data)
request_encrypted = encrypt_data(request_data, True)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "video_gen_request",
@@ -54,7 +54,7 @@ def _log_video_response(record_id: str, response_data: dict, error: str | None =
os.makedirs(LOG_DIR, exist_ok=True)
today = datetime.now().strftime(LOG_DATE_FORMAT)
log_file = os.path.join(LOG_DIR, f"{today}.log")
response_encrypted = encrypt_data(response_data) if response_data else ""
response_encrypted = encrypt_data(response_data, True) if response_data else ""
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+68 -62
View File
@@ -1,49 +1,64 @@
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
import asyncio
import httpx
import json
from typing import Optional
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
# 引入自定义北京时间工具类
from app.utils.datetime_util import BEIJING_TZ
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.models.base import async_session
from app.config import settings
from app.utils.redis import get_redis
from app.utils.logger import get_logger
from app.utils.datetime_util import datetime_to_db_tz_str, db_tz_str_to_datetime
REDIS_KEY = "douyin:tokens"
logger = get_logger("token_refresh", "token_refresh")
# 配置常量
REFRESH_THRESHOLD_SECONDS = 800
CHECK_INTERVAL_MINUTES = 5
HTTP_TIMEOUT = httpx.Timeout(30.0)
async def _delete_redis_token(oauth_id: str):
"""【改动1:废弃空值覆盖,直接删除Hash脏缓存】refresh失效则移除字段"""
redis = get_redis()
if not redis:
logger.warning("Redis连接未配置,跳过缓存清理")
return
try:
await redis.hdel(REDIS_KEY, oauth_id)
logger.info(f"清理失效授权Redis缓存: oauth_id={oauth_id}")
except Exception as e:
logger.error(f"删除Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime):
"""更新Redis缓存中的token"""
"""更新Redis缓存中的token(统一北京时间序列化)"""
redis = get_redis()
if not redis:
logger.warning("Redis连接未配置,跳过缓存更新")
return
try:
cache = {
"token": token,
"expired_at": expired_at.isoformat(),
}
"token": token,
"expired_at": datetime_to_db_tz_str(expired_at),
}
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
except Exception as e:
logger.error(f"更新Redis缓存失败: {str(e)}")
logger.error(f"更新Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession):
"""刷新巨量引擎token"""
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/"
response = await client.post(
url,
@@ -55,13 +70,11 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
#如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token
if data.get("code") in [40103, 40107]:
#清空数据库中的token信息,和Redis缓存中的token
from sqlalchemy import update
# refresh_token已失效或刷新失败,统一清理token
if data.get("code") in [40103, 40107, 40000]:
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
@@ -80,23 +93,20 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await _update_redis_token(related_id, "", None)
await _delete_redis_token(related_id)
return
resp_data = data.get("data", {})
new_access_token = resp_data.get("access_token", "")
new_refresh_token = resp_data.get("refresh_token", "")
data = data.get("data", {})
new_access_token = data.get("access_token", "")
new_refresh_token = data.get("refresh_token", "")
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
from sqlalchemy import update
# 【改动3:全部使用北京时间计算过期时间,统一时区】
now_beijing = datetime.now(tz=BEIJING_TZ)
expires_in = now_beijing + timedelta(seconds=resp_data.get("expires_in", 0))
refresh_expires = now_beijing + timedelta(seconds=resp_data.get("refresh_token_expires_in", 0))
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
@@ -111,19 +121,20 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
access_token=new_access_token,
access_token_expired=expires_in,
refresh_token=new_refresh_token,
refresh_token_expired=refresh_token_expires_in,
refresh_token_expired=refresh_expires,
)
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await _update_redis_token(related_id, new_access_token, expires_in)
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}")
logger.info(
f"成功刷新巨量引擎token: oauth_id={oauth.id}, "
f"account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}"
)
except httpx.HTTPError as e:
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
except Exception as e:
@@ -133,13 +144,14 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
async def check_and_refresh_tokens():
"""检查并刷新即将过期的token"""
async with async_session() as db:
now = datetime.now(timezone.utc)
# 【改动4:统一使用北京时间当前时间,避免时区运算异常】
now_beijing = datetime.now(tz=BEIJING_TZ)
query = select(UserOAuth).where(
UserOAuth.deleted_at.is_(None),
UserOAuth.refresh_token.is_not(None),
UserOAuth.refresh_token_expired.is_not(None),
UserOAuth.refresh_token_expired > now,
UserOAuth.refresh_token_expired > now_beijing,
)
result = await db.execute(query)
@@ -149,77 +161,71 @@ async def check_and_refresh_tokens():
for oauth in oauth_list:
try:
#检查是否为支持的平台(巨量引擎)
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
if oauth.port_type not in [1]:
continue
#构建登录账号唯一标识,同一登录账号共享token
# 唯一键优化:过滤空值避免拼接异常
key_parts = []
if oauth.appid:
key_parts.append(oauth.appid)
if oauth.account_username:
key_parts.append(oauth.account_username)
if oauth.account_userid:
key_parts.append(oauth.account_userid)
key_parts.append(str(oauth.account_userid))
login_key = "|".join(key_parts)
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
if login_key in refreshed_keys:
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
continue
#获取应用配置
# 【改动5:过滤已禁用、已删除应用】
app_result = await db.execute(
select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid)
select(UserOAuthApp).where(
UserOAuthApp.app_id == oauth.appid,
UserOAuthApp.status == 1,
UserOAuthApp.deleted_at.is_(None)
)
)
app = app_result.scalar_one_or_none()
if not app:
logger.warning(f"oauth_id:{oauth.id} 对应应用不存在/已禁用,跳过刷新")
continue
#检查access_token是否需要刷新
need_refresh = False
# access_token为空,需要刷新
if not oauth.access_token:
if not oauth.access_token or not oauth.access_token_expired:
need_refresh = True
# access_token_expired为空,需要刷新
elif not oauth.access_token_expired:
need_refresh = True
# access_token即将过期(剩余时间小于800秒),需要刷新
else:
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
if remaining_seconds < REFRESH_THRESHOLD_SECONDS:
# 同时区时间运算,不会抛异常
remain_sec = (oauth.access_token_expired - now_beijing).total_seconds()
if remain_sec < REFRESH_THRESHOLD_SECONDS:
need_refresh = True
if not need_refresh:
continue
#refresh_token已在查询条件中过滤,确保有效才能刷新
#刷新token
await refresh_juliang_token(oauth, app, db)
refreshed_keys.add(login_key)
# 简单限流,防止瞬间大量请求
await asyncio.sleep(0.2)
except Exception as e:
#7.增加错误日志
logger.error(f"刷新token失败: {str(e)}")
logger.error(f"oauth_id:{oauth.id} 刷新token异常: {str(e)}", exc_info=True)
async def token_refresh_scheduler():
"""定时任务调度器"""
logger.info("开始执行定时Token刷新任务")
while True:
try:
await check_and_refresh_tokens()
except Exception as e:
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}")
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}", exc_info=True)
await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60)
def start_token_refresh_task():
"""启动token刷新定时任务"""
logger.info("启动token刷新定时任务")
logger.info("启动token刷新定时后台任务")
asyncio.create_task(token_refresh_scheduler())
+270
View File
@@ -0,0 +1,270 @@
from datetime import datetime, timedelta, timezone, date
from typing import Optional, Union, Tuple
from dateutil import parser
# ===================== 时区与格式化常量(PHP风格映射) =====================
# 北京时间 东八区 UTC+8
BEIJING_TZ = timezone(timedelta(hours=8))
UTC_TZ = timezone.utc
# Python strftime 标准格式
FORMAT_DATETIME = "%Y-%m-%d %H:%M:%S"
FORMAT_DATE = "%Y-%m-%d"
FORMAT_TIME = "%H:%M:%S"
# PHP格式 -> Python格式 映射,贴近PHP使用习惯
PHP_FMT_MAP = {
"Y-m-d": FORMAT_DATE,
"Y-m-d H:i:s": FORMAT_DATETIME,
"H:i:s": FORMAT_TIME
}
# ===================== 私有公共工具函数(内部复用) =====================
def _normalize_date_str(date_str: str) -> str:
"""
预处理中文格式日期字符串,统一转为横杠分隔标准格式
支持:2025年01月01日 12时30分00秒 / 20250101 等格式
"""
if not isinstance(date_str, str):
return ""
s = date_str.strip()
# 中文、全角符号替换清洗
s = s.replace("", ":") \
.replace("", "-") \
.replace("", "-") \
.replace("", " ") \
.replace("", ":") \
.replace("", ":") \
.replace("", "")
return s
def _get_python_fmt(fmt: str) -> str:
"""兼容PHP格式字符串,自动转为Python strftime格式"""
return PHP_FMT_MAP.get(fmt, fmt)
def _convert_ts_to_datetime(timestamp: Union[int, float], tz: timezone = BEIJING_TZ) -> datetime:
"""
统一处理 10位秒 / 13位毫秒 时间戳 -> 带时区datetime
"""
ts = float(timestamp)
# 毫秒时间戳兼容
if ts > 10 ** 12:
ts /= 1000
return datetime.fromtimestamp(ts, tz=tz)
def _parse_datetime_with_tz(dt_str: str, fmt: str, tz: timezone = BEIJING_TZ) -> Optional[datetime]:
"""
通用:固定格式日期字符串解析+绑定时区,支持中文日期预处理
仅支持指定fmt格式,不支持带时区后缀字符串
"""
try:
fmt = _get_python_fmt(fmt)
normalize_str = _normalize_date_str(dt_str)
dt = datetime.strptime(normalize_str, fmt)
return dt.replace(tzinfo=tz)
except (ValueError, TypeError):
return None
def _smart_parse_datetime(dt_str: str, base_dt: datetime, is_timezone: bool) -> Optional[datetime]:
"""
私有:dateutil智能解析日期(用于带时区、相对时间、中文复杂日期)
"""
clean_str = _normalize_date_str(dt_str)
try:
parsed_dt = parser.parse(clean_str, default=base_dt, tzinfos=None)
if is_timezone:
# 保留原始时区,无时区则兜底北京时间
if parsed_dt.tzinfo is None:
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
else:
# 普通日期强制绑定北京时间
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
return parsed_dt
except (parser.ParserError, ValueError, TypeError):
return None
# ===================== 对外工具方法(对标PHP + dateutil增强) =====================
def time() -> int:
"""
对标 PHP time()
获取当前北京时间 10位 秒级时间戳
"""
return int(datetime.now(tz=BEIJING_TZ).timestamp())
def microtime(get_as_float: bool = False) -> Union[float, str]:
"""
对标 PHP microtime()
:param get_as_float: True 返回浮点时间戳,False 返回 "微秒 秒" 字符串
"""
now = datetime.now(tz=BEIJING_TZ)
ts = now.timestamp()
if get_as_float:
return ts
sec = int(ts)
usec = int((ts - sec) * 1000000)
return f"{usec:06d} {sec}"
def strtotime(
time_str: str,
now: Optional[int] = None,
is_timezone: bool = False
) -> Optional[int]:
"""
【增强版 对标 PHP strtotime】依托 dateutil 支持相对时间、带时区时间解析
:param time_str: 待解析日期/相对时间字符串
:param now: 基准时间戳,默认当前北京时间
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08
- True:优先使用字符串自带时区,无时区则兜底北京时间
- False:默认按北京时间解析普通日期字符串
:return: 秒级时间戳,解析失败返回 None
"""
if not isinstance(time_str, str) or not time_str.strip():
return None
# 基准时间:默认当前北京时间
base_dt = datetime.fromtimestamp(now, tz=BEIJING_TZ) if now else datetime.now(tz=BEIJING_TZ)
parsed_dt = _smart_parse_datetime(time_str, base_dt, is_timezone)
if not parsed_dt:
return None
return int(parsed_dt.timestamp())
def date(fmt: str, timestamp: Optional[int] = None) -> str:
"""
对标 PHP date()
时间戳格式化,默认北京时间
:param fmt: 支持 Y-m-d / Y-m-d H:i:s 或 %Y-%m-%d 原生格式
:param timestamp: 秒时间戳,None则取当前时间
"""
if timestamp is None:
dt = datetime.now(tz=BEIJING_TZ)
else:
dt = _convert_ts_to_datetime(timestamp)
python_fmt = _get_python_fmt(fmt)
return dt.strftime(python_fmt)
def timestamp_to_datetime(timestamp: Union[int, float], fmt: str = "Y-m-d H:i:s") -> str:
"""
时间戳(秒/毫秒)→ 北京时间格式化字符串
:param timestamp: 10位秒 /13位毫秒
:param fmt: PHP风格格式 Y-m-d / Y-m-d H:i:s
"""
dt = _convert_ts_to_datetime(timestamp)
python_fmt = _get_python_fmt(fmt)
return dt.strftime(python_fmt)
def datetime_to_timestamp(dt_str: str, fmt: str = "Y-m-d H:i:s") -> Optional[int]:
"""
日期字符串(含中文日期) → 北京时间秒时间戳
:param dt_str: 日期字符串
:param fmt: PHP风格格式化模板
:return: 秒时间戳,解析失败返回None
"""
dt = _parse_datetime_with_tz(dt_str, fmt)
if not dt:
return None
return int(dt.timestamp())
def get_today_start_timestamp() -> int:
"""获取今日 00:00:00 北京时间 秒时间戳"""
today: date = datetime.now(tz=BEIJING_TZ).date()
start_dt = datetime.combine(today, datetime.min.time(), tzinfo=BEIJING_TZ)
return int(start_dt.timestamp())
def get_today_end_timestamp() -> int:
"""获取今日 23:59:59.999999 北京时间 秒时间戳"""
today: date = datetime.now(tz=BEIJING_TZ).date()
end_dt = datetime.combine(today, datetime.max.time(), tzinfo=BEIJING_TZ)
return int(end_dt.timestamp())
def parse_date_range(date_list: list, fmt: str = "Y-m-d", is_timezone: bool = False) -> Optional[Tuple[datetime, datetime]]:
"""
时间范围解析:["2025-01-01","2026-01-01"] → (开始0点,结束23:59:59.999999) 带北京时间
:param date_list: 长度为2的日期字符串数组
:param fmt: 日期格式,默认Y-m-d
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08
- True:使用dateutil智能解析,优先保留字符串自带时区,无时区兜底北京时间
- False:按指定fmt格式精准解析,强制北京时间
:return: (start_dt, end_dt) 格式非法返回None
"""
# 强参数校验
if not isinstance(date_list, list) or len(date_list) != 2:
return None
start_str, end_str = date_list[0].strip(), date_list[1].strip()
if not start_str or not end_str:
return None
base_now = datetime.now(tz=BEIJING_TZ)
if is_timezone:
# 带时区场景:智能解析,支持 2026-07-03 08:16:26.88303+08
start_dt = _smart_parse_datetime(start_str, base_now, is_timezone=True)
end_dt = _smart_parse_datetime(end_str, base_now, is_timezone=True)
else:
# 普通前端日期:固定格式解析
start_dt = _parse_datetime_with_tz(start_str, fmt, BEIJING_TZ)
end_dt = _parse_datetime_with_tz(end_str, fmt, BEIJING_TZ)
if not start_dt or not end_dt:
return None
# 结束时间补全到当日最后一毫秒
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def db_tz_str_to_datetime(db_datetime_str: str) -> Optional[datetime]:
"""
数据库timestamptz格式字符串(2026-07-03 08:16:26.88303+08)转为带时区datetime
"""
try:
dt = datetime.fromisoformat(db_datetime_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=BEIJING_TZ)
return dt
except (ValueError, TypeError):
return None
def datetime_to_db_tz_str(dt: datetime) -> str:
"""
带时区datetime转为数据库timestamptz标准字符串,统一北京时间存储
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=BEIJING_TZ)
else:
dt = dt.astimezone(BEIJING_TZ)
return dt.isoformat()
# 导出列表
__all__ = [
"BEIJING_TZ",
"UTC_TZ",
"FORMAT_DATETIME",
"FORMAT_DATE",
"FORMAT_TIME",
"time",
"microtime",
"strtotime",
"date",
"timestamp_to_datetime",
"datetime_to_timestamp",
"get_today_start_timestamp",
"get_today_end_timestamp",
"parse_date_range",
"db_tz_str_to_datetime",
"datetime_to_db_tz_str"
]
+116 -84
View File
@@ -5,8 +5,14 @@ from typing import Any, Dict, Optional, Tuple
import httpx
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta
# 【改动1:引入自定义北京时间工具类】
from app.utils.datetime_util import (
BEIJING_TZ,
datetime_to_db_tz_str,
db_tz_str_to_datetime
)
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.models.base import async_session
@@ -40,50 +46,75 @@ class DouyinRequest:
self._client = None
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
"""
【核心改动:北京时间解析+脏缓存自动清理】
1. 缓存格式异常/字段缺失:直接删除脏缓存
2. Token过期则不返回,交由上层判断refresh是否过期决定是否删除缓存
"""
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
cache_str = await redis.hget(self._redis_key, oauth_id)
if cache_str:
cache = json.loads(cache_str)
token = cache.get("token")
expired_at_str = cache.get("expired_at")
if token and expired_at_str:
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
if expired_at > datetime.now(timezone.utc):
return token
except Exception as e:
raise ValueError(f"获取Redis缓存失败: {e}")
if not cache_str:
return None
return None
cache = json.loads(cache_str)
token = cache.get("token")
expired_at_str = cache.get("expired_at")
# 脏缓存:关键字段缺失,直接删除
if not (token and expired_at_str):
await redis.hdel(self._redis_key, oauth_id)
logger.warning(f"oauth_id:{oauth_id} Redis缓存字段缺失,已清理脏数据")
return None
# 【改动3:使用工具类解析带时区时间,不再强制覆盖UTC】
expired_at = db_tz_str_to_datetime(expired_at_str)
now_beijing = datetime.now(tz=BEIJING_TZ)
if expired_at > now_beijing:
return token
# AccessToken已过期,返回None,上层会校验refresh_token状态决定是否清理缓存
return None
except json.JSONDecodeError:
# JSON格式损坏,清理脏缓存
await redis.hdel(self._redis_key, oauth_id)
logger.error(f"oauth_id:{oauth_id} Redis缓存JSON格式异常,已清理脏数据")
return None
except Exception as e:
raise ValueError(f"获取Redis缓存失败: {str(e)}")
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
# 【改动4:统一转为北京时间序列化存入Redis,和数据库时区对齐】
cache = {
"token": token,
"expired_at": datetime_to_db_tz_str(expired_at),
}
try:
cache = {
"token": token,
"expired_at": expired_at.isoformat(),
}
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
except Exception as e:
raise ValueError(f"设置Redis缓存失败: {e}")
raise ValueError(f"设置Redis缓存失败: {str(e)}")
async def _delete_redis_token(self, oauth_id: str):
"""删除单个oauth_id缓存(授权彻底失效时调用)"""
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
await redis.hdel(self._redis_key, oauth_id)
logger.info(f"oauth_id:{oauth_id} 授权失效,已清理Redis缓存脏数据")
except Exception as e:
raise ValueError(f"删除Redis缓存失败: {e}")
raise ValueError(f"删除Redis缓存失败: {str(e)}")
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
# 优先读取缓存
if not force_refresh:
token = await self._get_redis_token(oauth_id)
if token:
@@ -101,7 +132,17 @@ class DouyinRequest:
if not oauth_data:
raise ValueError("无效的oauth_id")
# 【改动5:统一北京时间当前时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
if force_refresh:
# 强制刷新:先检查refresh_token是否为空
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
# 清空同条件下所有账号缓存与数据库token
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
@@ -118,9 +159,7 @@ class DouyinRequest:
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await self._delete_redis_token(related_id)
@@ -128,12 +167,14 @@ class DouyinRequest:
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
return new_token
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
# 数据库AccessToken未过期,写入缓存直接返回
if oauth_data.access_token_expired and oauth_data.access_token_expired > now_beijing:
token = oauth_data.access_token
expired_at = oauth_data.access_token_expired
await self._set_redis_token(oauth_id, token, expired_at)
return token
# 查找同账号下未过期有效token复用
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
@@ -146,7 +187,7 @@ class DouyinRequest:
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
where_cond,
UserOAuth.access_token_expired.is_not(None),
UserOAuth.access_token_expired > datetime.now(timezone.utc),
UserOAuth.access_token_expired > now_beijing,
).limit(1)
)
related_oauth = related_oauths.first()
@@ -155,9 +196,18 @@ class DouyinRequest:
await self._set_redis_token(oauth_id, token, expired_at)
return token
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
raise ValueError("授权已过期,请重新授权")
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
if not oauth_data.refresh_token:
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < now_beijing:
# RefreshToken过期 → 授权彻底失效,清理Redis脏缓存
await self._delete_redis_token(oauth_id)
raise ValueError("授权已过期,请重新授权登录")
# RefreshToken有效,执行刷新并更新缓存
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
return new_token
@@ -203,6 +253,7 @@ class DouyinRequest:
code = data.get('code', 0)
if code != 0:
#刷新token
raise ValueError(f"刷新access_token失败,接口返回:{data}")
data = data.get('data', {})
@@ -211,7 +262,10 @@ class DouyinRequest:
expires_in = data.get('expires_in', 0)
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
# 【改动6:北京时间计算过期时间】
now_beijing = datetime.now(tz=BEIJING_TZ)
new_expired_at = now_beijing + timedelta(seconds=expires_in)
new_refresh_expired = now_beijing + timedelta(seconds=refresh_token_expires_in)
where_cond = UserOAuth.deleted_at.is_(None)
if appid:
@@ -222,20 +276,16 @@ class DouyinRequest:
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
await db.execute(
update(UserOAuth).where(
where_cond,
).values(
update(UserOAuth).where(where_cond).values(
access_token=new_access_token,
refresh_token=new_refresh_token,
access_token_expired=new_expired_at,
refresh_token_expired=datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in),
refresh_token_expired=new_refresh_expired,
)
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
@@ -243,92 +293,79 @@ class DouyinRequest:
return new_access_token, new_expired_at
# 有token请求
async def request_with_token_with_context(
self,
oauth_id: str,
url: str,
method: str = 'GET',
options: any = None,
options: Optional[Dict[str, Any]] = None,
request_count: int = 1,
) -> Any:
options = options or {}
token = await self.get_access_token(oauth_id)
resp_data: Optional[Dict[str, Any]] = None
for i in range(1, request_count+1):
# 有一些错误是触发频次管理的,需要重试
for i in range(1, request_count + 1):
try:
headers = options.get('headers', {}).copy()
headers['Access-Token'] = token
has_files = 'files' in options
if has_files:
# 移除可能错误设置的 Content-Type,让库自动生成 multipart 头
headers.pop('Content-Type', None)
else:
headers.setdefault('Content-Type', 'application/json')
options['headers'] = headers
response = await self.client.request(method, url, **options)
response.raise_for_status()
try:
data = response.json()
resp_data = response.json()
except json.JSONDecodeError:
data = {'code': 0, 'data': response.text, 'msg':'JSON解析失败'}
resp_data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
if 'code' not in data:
if 'code' not in resp_data:
await asyncio.sleep(i * 5)
continue
code = data.get('code', 0)
code = resp_data.get('code', 0)
if code in [40102, 40104]:
await asyncio.sleep(i * 5)
token = await self.get_access_token(oauth_id, force_refresh=True)
continue
if code in [40100, 40110]:
wait_time = min(2 * (2 ** (i - 1)), 10)
await asyncio.sleep(wait_time)
continue
if code == 50000:
await asyncio.sleep(i * 10)
continue
return data
return resp_data
except httpx.HTTPStatusError as e:
await asyncio.sleep(i * 10)
continue
except httpx.RequestError as e:
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
# 重试耗尽,日志记录
options_log = {}
if options:
for key, value in options.items():
if key == 'files':
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
else:
options_log[key] = value
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
for key, value in options.items():
if key == 'files':
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
else:
options_log[key] = value
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
logger.error(
f'DouYin API request failed after {request_count} retries. '
f'url:{url};method:{method};oauth_id:{oauth_id};options:{json.dumps(options_log, ensure_ascii=False)};response:{res}'
)
if 'data' in locals() and data.get('code', 0) != 0:
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
else:
raise ValueError('网络错误,稍后重试。')
if resp_data and resp_data.get("code", 0) != 0:
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
raise ValueError("网络请求失败,请稍后重试")
# 无token请求
async def request_with_context(
self,
url: str,
@@ -336,8 +373,9 @@ class DouyinRequest:
options: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
options = options or {}
resp_data: Optional[Dict[str, Any]] = None
for i in range(1, 2):
for i in range(1, 3):
try:
headers = options.get('headers', {}).copy()
headers.setdefault('Content-Type', 'application/json')
@@ -347,30 +385,24 @@ class DouyinRequest:
response.raise_for_status()
try:
data = response.json()
resp_data = response.json()
except json.JSONDecodeError:
data = {'code': 0, 'data': response.text}
resp_data = {'code': 0, 'data': response.text}
if 'code' not in data:
if 'code' not in resp_data:
await asyncio.sleep(i * 5)
continue
code = data.get('code', 0)
if code >= 50000:
if resp_data.get("code", 0) >= 50000:
await asyncio.sleep(i * 10)
continue
return resp_data
return data
except httpx.HTTPStatusError as e:
await asyncio.sleep(i * 10)
continue
except httpx.RequestError as e:
except (httpx.HTTPStatusError, httpx.RequestError):
await asyncio.sleep(i * 10)
continue
res = json.dumps(data) if 'data' in locals() else ''
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
raise RuntimeError(
f'DouYin API request failed after 5 retries. '
f'url:{url};options:{json.dumps(options)};response:{res}'
f'DouYin API request failed after 2 retries. '
f'url:{url};options:{json.dumps(options, ensure_ascii=False)};response:{res}'
)
+139
View File
@@ -0,0 +1,139 @@
from typing import Any, Dict, Optional
from app.utils.kuaishouRequest import KuaishouRequest
from app.models.user_oauth import UserOAuth
class KuaishouApi:
def __init__(self):
self.request = KuaishouRequest()
# 获取广告主信息
async def get_advertiser_info(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/info"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
# 上传图片素材
async def upload_image_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/file/ad/image/upload"
options: Dict[str, Any] = {}
if data:
options['data'] = data
if files:
options['files'] = files
return await self.request.request_with_token_with_context(
oauth_id,
url,
'POST',
options,
request_count=3
)
# 上传视频素材
async def upload_video_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/file/ad/video/upload"
options: Dict[str, Any] = {}
if data:
options['data'] = data
if files:
options['files'] = files
return await self.request.request_with_token_with_context(
oauth_id,
url,
'POST',
options,
request_count=3
)
# 获取地域信息
async def get_area(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/tools/admin/info"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
# 获取素材消耗(报表查询)
async def get_material_cost(self, oauth_id: str, params: any, request_count: int = 3) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/report/get"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}},
request_count=request_count
)
# 获取账户信息
async def get_account_info(self, oauth_id: str, params: any) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/info"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
# 获取广告计划列表
async def get_ad_list(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/ad/list"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
# 获取广告组列表
async def get_ad_unit_list(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v2/ad_unit/list"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
# 获取账户余额
async def get_account_balance(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/balance"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
+276
View File
@@ -0,0 +1,276 @@
import json
import asyncio
from typing import Any, Dict, Optional, Tuple
import httpx
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone, timedelta
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.models.base import async_session
from app.utils.redis import get_redis
from app.utils.logger import get_logger
logger = get_logger("kuaishou_request", "kuaishou_request")
class KuaishouRequest:
def __init__(self, platform: str = "kuaishou"):
self._client: Optional[httpx.AsyncClient] = None
self._platform = platform
@property
def client(self) -> httpx.AsyncClient:
if not self._client:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
)
return self._client
@property
def _redis_key(self) -> str:
return f"{self._platform}:tokens"
async def close(self):
if self._client:
await self._client.aclose()
self._client = None
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
cache_str = await redis.hget(self._redis_key, oauth_id)
if cache_str:
cache = json.loads(cache_str)
token = cache.get("token")
expired_at_str = cache.get("expired_at")
if token and expired_at_str:
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
if expired_at > datetime.now(timezone.utc):
return token
except Exception as e:
raise ValueError(f"获取Redis缓存失败: {e}")
return None
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
cache = {
"token": token,
"expired_at": expired_at.isoformat(),
}
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
except Exception as e:
raise ValueError(f"设置Redis缓存失败: {e}")
async def _delete_redis_token(self, oauth_id: str):
redis = get_redis()
if not redis:
raise ValueError("Redis连接未配置")
try:
await redis.hdel(self._redis_key, oauth_id)
except Exception as e:
raise ValueError(f"删除Redis缓存失败: {e}")
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
if not force_refresh:
token = await self._get_redis_token(oauth_id)
if token:
return token
async with async_session() as db:
oauth_data = await db.execute(
select(UserOAuth).where(
UserOAuth.id == oauth_id,
UserOAuth.deleted_at.is_(None),
).limit(1)
)
oauth_data = oauth_data.scalar_one_or_none()
if not oauth_data:
raise ValueError("无效的oauth_id")
if force_refresh:
await db.execute(
update(UserOAuth).where(UserOAuth.id == oauth_id).values(
access_token=None,
access_token_expired=None,
)
)
await db.commit()
await self._delete_redis_token(oauth_id)
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
await self._set_redis_token(oauth_id, new_token, new_expired_at)
return new_token
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
token = oauth_data.access_token
expired_at = oauth_data.access_token_expired
await self._set_redis_token(oauth_id, token, expired_at)
return token
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
raise ValueError("授权已过期,请重新授权")
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
await self._set_redis_token(oauth_id, new_token, new_expired_at)
return new_token
async def refresh_access_token(self, db: AsyncSession, oauth_id: str, appid: str, refresh_token: str) -> Tuple[str, datetime]:
result = await db.execute(
select(UserOAuthApp.secret).where(
UserOAuthApp.app_id == appid,
UserOAuthApp.status == 1,
UserOAuthApp.deleted_at.is_(None),
).limit(1)
)
app_secret = result.scalar_one_or_none()
if not app_secret:
raise ValueError("应用已被删除或禁用")
# 快手刷新token接口
response = await self.client.request(
'POST',
'https://ad.e.kuaishou.com/rest/openapi/oauth2/authorize/refresh_token',
data={
'app_id': appid,
'secret': app_secret,
'refresh_token': refresh_token,
},
)
response.raise_for_status()
data = response.json()
if 'code' not in data:
raise ValueError("刷新access_token失败,接口未返回code")
code = data.get('code', 0)
if code != 0:
raise ValueError(f"刷新access_token失败,接口返回:{data}")
data = data.get('data', {})
new_access_token = data.get('access_token', '')
new_refresh_token = data.get('refresh_token', '')
expires_in = data.get('access_token_expires_in', 0)
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
await db.execute(
update(UserOAuth).where(
UserOAuth.id == oauth_id,
).values(
access_token=new_access_token,
refresh_token=new_refresh_token,
access_token_expired=new_expired_at,
refresh_token_expired=datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in),
)
)
await db.commit()
return new_access_token, new_expired_at
# 有token请求
async def request_with_token_with_context(
self,
oauth_id: str,
url: str,
method: str = 'GET',
options: any = None,
request_count: int = 3,
) -> Any:
options = options or {}
token = await self.get_access_token(oauth_id)
for i in range(1, request_count+1):
try:
headers = options.get('headers', {}).copy()
headers['Access-Token'] = token
has_files = 'files' in options
if has_files:
headers.pop('Content-Type', None)
else:
headers.setdefault('Content-Type', 'application/json')
options['headers'] = headers
response = await self.client.request(method, url, **options)
response.raise_for_status()
try:
data = response.json()
except json.JSONDecodeError:
data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
# 如果没有code,直接返回数据,快手触发接口频次会返回空
if 'code' not in data:
await asyncio.sleep(i * 5)
continue
code = data.get('code', 0)
# 检查是否需要刷新令牌
if code in [402000, 400003, 402007, 402005, 402004, 401000]:
# 如果message包含 '该账户不在您的代理商下',说明账户已经转走了,不需要刷新token,直接返回
message = data.get('message', '')
if '该账户不在您的代理商下' in message:
return data
await asyncio.sleep(i * 5)
token = await self.get_access_token(oauth_id, force_refresh=True)
continue
# 检查是否触发接口频次
if code in [400001, 402007, 402008, 410000, 410001]:
await asyncio.sleep(i * 5)
continue
# 服务端错误
if code >= 500000:
await asyncio.sleep(i * 5)
continue
# 其他错误直接返回数据
return data
except httpx.HTTPStatusError as e:
await asyncio.sleep(i * 10)
continue
except httpx.RequestError as e:
await asyncio.sleep(i * 10)
continue
options_log = {}
if options:
for key, value in options.items():
if key == 'files':
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
else:
options_log[key] = value
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
logger.error(
f'KuaiShou API request failed after {request_count} retries. '
f'url:{url};method:{method};oauth_id:{oauth_id};options:{json.dumps(options_log, ensure_ascii=False)};response:{res}'
)
if 'data' in locals() and data.get('code', 0) != 0:
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
else:
raise ValueError('网络错误,稍后重试。')
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CyYhkNba.js"></script>
<script type="module" crossorigin src="/assets/index-CclRZKjR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
+14 -3
View File
@@ -113,6 +113,20 @@ export async function optimizePrompt(
image_px: params.image_px || null,
});
}
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
@@ -721,9 +735,6 @@ export async function getHomeCaseHeader(): Promise<any> {
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
}
export async function deleteResourcesMaterial(params:any): Promise<any> {
return api.delete(`/generation-ai/history/batch`, params);
}
@@ -62,7 +62,7 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword } from '../../api';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
@@ -619,10 +619,18 @@ const AppLayout: React.FC = () => {
const handleChangePwd = async () => {
try {
await pwdForm.validateFields();
message.success('密码修改成功(演示)');
setPwdModalOpen(false); pwdForm.resetFields();
} catch { }
const values = await pwdForm.validateFields();
await changePassword(values.oldPwd, values.newPwd);
message.success('密码修改成功');
setPwdModalOpen(false);
pwdForm.resetFields();
} catch (error: any) {
if (error?.response?.data?.detail) {
message.error(error.response.data.detail);
} else if (error?.message) {
message.error(error.message);
}
}
};
const handleLogout = () => {
+167 -26
View File
@@ -25,7 +25,7 @@ import text from '../assets/testb.png';
import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api';
@@ -51,6 +51,7 @@ import {
DownloadOutlined,
ReloadOutlined,
AudioOutlined,
PauseOutlined,
} from '@ant-design/icons';
@@ -227,6 +228,38 @@ const AIChatPage: React.FC = () => {
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (previewVisible && previewType === 'video') {
const playVideo = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
}
};
if (videoRef.current) {
if (videoRef.current.readyState >= 2) {
playVideo();
} else {
videoRef.current.addEventListener('loadedmetadata', playVideo);
}
}
const timer = setTimeout(playVideo, 300);
return () => {
clearTimeout(timer);
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', playVideo);
videoRef.current.pause();
}
};
} else {
if (videoRef.current) {
videoRef.current.pause();
}
}
}, [previewVisible, previewType]);
// 提示词展开状态
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
@@ -278,6 +311,8 @@ const AIChatPage: React.FC = () => {
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
const [audioProgress, setAudioProgress] = useState(0);
// 附件详情悬浮窗状态
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
@@ -831,6 +866,9 @@ const AIChatPage: React.FC = () => {
}
}
console.log(mediaReferences);
// 创建用户消息对象
const newMessage: Message = {
id: '',
@@ -994,6 +1032,35 @@ const AIChatPage: React.FC = () => {
});
};
const getAudioDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const audio = document.createElement('audio');
audio.preload = 'metadata';
audio.onloadedmetadata = () => {
URL.revokeObjectURL(audio.src);
resolve(audio.duration);
};
audio.onerror = () => {
URL.revokeObjectURL(audio.src);
reject(new Error('无法获取音频时长'));
};
audio.src = URL.createObjectURL(file);
});
};
const handleAudioPlay = (url: string) => {
const audioUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
if (playingAudioUrl === audioUrl) {
const audio = document.getElementById('audio-player') as HTMLAudioElement;
if (audio) {
audio.pause();
}
setPlayingAudioUrl(null);
} else {
setPlayingAudioUrl(audioUrl);
}
};
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -1096,16 +1163,24 @@ const AIChatPage: React.FC = () => {
return false;
}
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImage = currentEngine?.maxImageCount ?? 4;
const maxVideo = currentEngine?.maxVideoCount ?? 1;
if (isAudio) {
const audioExt = file.name.split('.').pop()?.toLowerCase();
if (!['wav', 'mp3'].includes(audioExt || '')) {
message.error('音频仅支持wav和mp3格式');
return false;
}
}
if (mediaType === 'image' && (isVideo || isAudio)) {
message.error('图片模式仅支持上传图片');
return false;
}
if (isAudio && mediaType !== 'video') {
message.error('仅视频模式支持上传音频');
return false;
}
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
if (file.size / 1024 / 1024 > maxMB) {
@@ -1140,7 +1215,14 @@ const AIChatPage: React.FC = () => {
return false;
}
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
if (isAudio && audioCount >= maxAudio) {
message.error(`该引擎最多上传${maxAudio}个音频`);
return false;
}
let videoDuration = 0;
let audioDuration = 0;
if (isVideo) {
try {
videoDuration = await getVideoDuration(file);
@@ -1168,10 +1250,31 @@ const AIChatPage: React.FC = () => {
}
}
if (isAudio) {
try {
audioDuration = await getAudioDuration(file);
if (audioDuration < 2) {
message.error('音频素材最短不能少于 2 秒');
return false;
}
const existingAudioDuration = currentMedia
.filter((m) => m.type === 'audio')
.reduce((sum, m) => sum + (m.duration || 0), 0);
if (existingAudioDuration + audioDuration > 15) {
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)}`);
return false;
}
} catch {
message.error('无法获取音频信息,请检查文件是否损坏');
return false;
}
}
setUploading(true);
try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadImage : uploadVideo);
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
const res = await uploadFn(file);
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const newList = [...currentMedia, {
@@ -1179,7 +1282,8 @@ const AIChatPage: React.FC = () => {
type: mediaType,
url: res.url,
label: '',
...((isVideo || isAudio) && { duration: videoDuration }),
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
}];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
@@ -1287,10 +1391,11 @@ const AIChatPage: React.FC = () => {
document.body.removeChild(link);
};
const getAttachmentMediaType = (ref: any): 'image' | 'video' => {
const getAttachmentMediaType = (ref: any): 'image' | 'video' | 'audio' => {
const rawType = String(ref?.type || '').toLowerCase();
const rawUrl = String(ref?.url || ref?.name || '').toLowerCase();
if (rawType.includes('video') || /\.(mp4|mov|avi|webm|m4v)(\?|$)/.test(rawUrl)) return 'video';
if (rawType.includes('audio') || /\.(mp3|wav|ogg|aac|m4a)(\?|$)/.test(rawUrl)) return 'audio';
return 'image';
};
@@ -1320,7 +1425,8 @@ const AIChatPage: React.FC = () => {
if (!ref?.url) return;
const link = document.createElement('a');
link.href = buildAttachmentDownloadUrl(ref.url);
link.download = ref.name || (getAttachmentMediaType(ref) === 'image' ? 'image.png' : 'video.mp4');
const refType = getAttachmentMediaType(ref);
link.download = ref.name || (refType === 'image' ? 'image.png' : refType === 'video' ? 'video.mp4' : 'audio.mp3');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -1348,6 +1454,10 @@ const AIChatPage: React.FC = () => {
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
const maxImage = maxImageCount;
const maxVideo = maxVideoCount;
const maxAudio = currentEngine?.maxAudioCount ?? 1;
return (
<Layout className="ai-create-page" style={{
margin: '-24px -32px -32px',
@@ -1357,6 +1467,14 @@ const AIChatPage: React.FC = () => {
overflow: 'hidden',
}}>
{/* 隐藏的音频播放器 */}
<audio
id="audio-player"
src={playingAudioUrl || ''}
autoPlay
onEnded={() => setPlayingAudioUrl(null)}
style={{ display: 'none' }}
/>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
@@ -1390,6 +1508,7 @@ const AIChatPage: React.FC = () => {
</Button>
)}
{!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
{conversations.map((conversation) => (
@@ -1527,7 +1646,6 @@ const AIChatPage: React.FC = () => {
paddingBottom: 18,
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
background: '#fff',
// borderRadius: 22,
}}
>
@@ -1961,7 +2079,7 @@ const AIChatPage: React.FC = () => {
<div className="ai-reference-tray-scroll" style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '2px 2px 4px' }}>
{attachmentRefs.map((ref: any, idx: number) => {
const refType = getAttachmentMediaType(ref);
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : '视频'}${idx + 1}`);
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : refType === 'video' ? '视频' : '音频'}${idx + 1}`);
return (
<div
key={`${ref.url || ref.name || idx}-${idx}`}
@@ -1977,10 +2095,15 @@ const AIChatPage: React.FC = () => {
}}
>
<div
onClick={() => {
openAttachmentPreview(ref);
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
onClick={(e) => {
if (refType === 'audio') {
e.stopPropagation();
handleAudioPlay(ref.url);
} else {
openAttachmentPreview(ref);
setAttachmentPopupVisible(false);
setAttachmentPopupMessageId(null);
}
}}
style={{
position: 'relative',
@@ -1999,7 +2122,7 @@ const AIChatPage: React.FC = () => {
alt={ref.name || label}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
) : refType === 'video' ? (
<>
<video
src={buildAttachmentAssetUrl(ref.url)}
@@ -2013,6 +2136,14 @@ const AIChatPage: React.FC = () => {
</div>
</div>
</>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' }}>
{playingAudioUrl === (ref.url.startsWith('http') ? ref.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`) ? (
<PauseOutlined style={{ fontSize: 24, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 24, color: '#fff' }} />
)}
</div>
)}
{/* <span style={{ position: 'absolute', left: 6, top: 6, padding: '2px 6px', borderRadius: 999, background: 'rgba(255,255,255,0.92)', color: '#8b5cf6', fontSize: 10, fontWeight: 800, boxShadow: '0 4px 10px rgba(31, 41, 55, 0.08)' }}>
{label}
@@ -2047,7 +2178,7 @@ const AIChatPage: React.FC = () => {
>
</button>
<button
{/* <button
onClick={(e) => downloadAttachmentRef(ref, e)}
title="下载"
style={{
@@ -2067,7 +2198,7 @@ const AIChatPage: React.FC = () => {
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
>
<DownloadOutlined style={{ fontSize: 12 }} />
</button>
</button> */}
</div>
</div>
);
@@ -2092,8 +2223,12 @@ const AIChatPage: React.FC = () => {
backdropFilter: 'blur(24px)',
}}
>
{/* <div>12312</div> */}
{/* 上方输入布局 */}
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
{/* 左侧附件区域 */}
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
@@ -2223,7 +2358,9 @@ const AIChatPage: React.FC = () => {
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}>
<div
style={{
@@ -2319,15 +2456,17 @@ const AIChatPage: React.FC = () => {
/>
) : (
<div
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('audio');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
onClick={(e) => {
e.stopPropagation();
handleAudioPlay(media.url);
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
)}
</div>
)}
{/* 右上角删除按钮 */}
@@ -2378,7 +2517,9 @@ const AIChatPage: React.FC = () => {
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}>
<div
style={{
+3 -1
View File
@@ -771,7 +771,8 @@ const HomePage: React.FC = () => {
{/* ========== 素材案例区域 ========== */}
<div className="animate-fadeInUp" style={{
{caseAssets.length > 0 && (
<div className="animate-fadeInUp" style={{
padding: '24px 28px',
borderRadius: 16,
background: '#fff',
@@ -901,6 +902,7 @@ const HomePage: React.FC = () => {
))}
</div>
</div>
)}
{/* ========== 预览弹窗 ========== */}
<Modal
+145 -9
View File
@@ -62,6 +62,71 @@ function InitialInfo() {
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
// 预览相关状态
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = React.useRef<HTMLVideoElement>(null);
useEffect(() => {
if (previewVisible && previewType === 'video') {
const playVideo = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
}
};
if (videoRef.current) {
if (videoRef.current.readyState >= 2) {
playVideo();
} else {
videoRef.current.addEventListener('loadedmetadata', playVideo);
}
}
const timer = setTimeout(playVideo, 300);
return () => {
clearTimeout(timer);
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', playVideo);
videoRef.current.pause();
}
};
} else {
if (videoRef.current) {
videoRef.current.pause();
}
}
}, [previewVisible, previewType]);
const openPreview = (url: string, type: 'image' | 'video') => {
console.log(url, type);
setPreviewUrl(url);
setPreviewType(type);
setPreviewVisible(true);
};
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
};
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!previewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
//
const baseSteps = [
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
@@ -347,9 +412,12 @@ function InitialInfo() {
image_size: "2K"
}
gettwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
message.info('正在生成图片,请稍候...');
// 重新获取任务详情以更新数据
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const newcreateimage = () => {
@@ -362,9 +430,13 @@ function InitialInfo() {
}
gettwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
message.info('正在生成图片,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
@@ -385,7 +457,10 @@ function InitialInfo() {
getthree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
// 重新获取任务详情以更新数据
refreshTaskDetail();
message.info('正在生成视频提示词,请稍候...');
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
// 这里可以添加下一步的逻辑,比如调用接口等
};
@@ -398,8 +473,11 @@ function InitialInfo() {
getfour(taskDetail.id, stepId.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const agincreatevideo = () => {
@@ -410,6 +488,8 @@ function InitialInfo() {
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
});
@@ -503,10 +583,10 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
{taskDetail?.material?.materialVideoUrl ? (
<video
controls
src={taskDetail.material.materialVideoUrl}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -520,7 +600,7 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
{taskDetail?.material?.materialImageUrl ? (
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
@@ -537,7 +617,7 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalImageUrl, 'image')}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
alt=""
@@ -554,9 +634,9 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<video
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -700,7 +780,7 @@ function InitialInfo() {
</Button>
<Button
onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }}
onClick={() => { createimage(step.id); }}
type="primary"
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={step.status !== 'completed'}
@@ -1106,7 +1186,7 @@ function InitialInfo() {
<Button
type="primary"
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
onClick={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
onClick={() => { handleNextStep(step.id); }}
disabled={step.status !== 'completed'}
>
@@ -1132,7 +1212,7 @@ function InitialInfo() {
/
</Button>
<Button
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
onClick={() => { createvideo(step.id, step.engineId); }}
type="primary"
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
disabled={step.status !== 'completed'}
@@ -1433,6 +1513,62 @@ function InitialInfo() {
scroll={{ y: 350 }}
/>
</Modal>
{/* 图片/视频预览弹窗 */}
<Modal
open={previewVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #8b5cf6 0%, #ddd6fe 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, color: '#8b5cf6', letterSpacing: 0.4 }}>
</span>
</div>
}
onCancel={handleClosePreview}
width={800}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(139, 92, 246, 0.08)' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
style={{ borderRadius: 10, background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)', border: 'none', boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' }}
>
</Button>
</div>
}
centered
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
},
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(139, 92, 246, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{previewType === 'image' ? (
<img
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={videoRef}
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
</React.Fragment>
);
}
+15 -1
View File
@@ -82,6 +82,7 @@ interface MaterialData {
const MaterialListPage: React.FC = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const videoRef = React.createRef<HTMLVideoElement>();
const [records, setRecords] = useState<any[]>([]);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [listLoading, setListLoading] = useState(false);
@@ -93,6 +94,18 @@ const MaterialListPage: React.FC = () => {
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const handleClosePreview = () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
const videoElements = document.querySelectorAll('video');
videoElements.forEach(video => {
video.pause();
video.currentTime = 0;
});
setPreviewModalOpen(false);
};
const [pushPreTestTemplate, setPushPreTestTemplate] = useState<string>('');
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
@@ -528,7 +541,7 @@ const MaterialListPage: React.FC = () => {
<Modal
title="预览"
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
onCancel={handleClosePreview}
footer={null}
width={600}
style={{ borderRadius: 16 }}
@@ -536,6 +549,7 @@ const MaterialListPage: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
{previewType === 'video' ? (
<video
ref={videoRef}
src={previewUrl}
controls
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
+148 -10
View File
@@ -62,6 +62,44 @@ function InitialInfo() {
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
// 预览相关状态
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = React.useRef<HTMLVideoElement>(null);
useEffect(() => {
if (previewVisible && previewType === 'video') {
const playVideo = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
}
};
if (videoRef.current) {
if (videoRef.current.readyState >= 2) {
playVideo();
} else {
videoRef.current.addEventListener('loadedmetadata', playVideo);
}
}
const timer = setTimeout(playVideo, 300);
return () => {
clearTimeout(timer);
if (videoRef.current) {
videoRef.current.removeEventListener('loadedmetadata', playVideo);
videoRef.current.pause();
}
};
} else {
if (videoRef.current) {
videoRef.current.pause();
}
}
}, [previewVisible, previewType]);
//
const baseSteps = [
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
@@ -335,12 +373,42 @@ function InitialInfo() {
}).catch((error: any) => {
});
};
const openPreview = (url: string, type: 'image' | 'video') => {
setPreviewUrl(url);
setPreviewType(type);
setPreviewVisible(true);
};
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
};
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!previewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const createone = (stepId: number) => {
removeone(taskDetail.id, stepId.toString()).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成图片提示词,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
@@ -353,8 +421,11 @@ function InitialInfo() {
}
removetwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成图片,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const newcreateimage = () => {
@@ -367,9 +438,13 @@ function InitialInfo() {
}
removetwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
message.info('正在生成图片,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
@@ -389,8 +464,11 @@ function InitialInfo() {
// console.log('引擎 ID:', engineId);
removethree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频提示词,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
// 这里可以添加下一步的逻辑,比如调用接口等
};
@@ -403,8 +481,12 @@ function InitialInfo() {
removefour(taskDetail.id, stepId.toString(), params).then((res: any) => {
// 重新获取任务详情以更新数据
message.info('正在生成视频,请稍候...');
refreshTaskDetail();
}).catch((error: any) => {
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
message.error(errorMsg);
});
}
const agincreatevideo = () => {
@@ -504,10 +586,10 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
{taskDetail?.material?.materialVideoUrl ? (
<video
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -521,7 +603,7 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
{taskDetail?.material?.materialImageUrl ? (
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
@@ -537,7 +619,7 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalImageUrl, 'image')}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
alt=""
@@ -554,9 +636,9 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<video
controls
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
@@ -675,7 +757,7 @@ function InitialInfo() {
)}
</div>
</div>
<Button onClick={() => { message.info('正在生成图片提示词,请稍候...'); createone(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
<Button onClick={() => { createone(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
</Button>
</div>
@@ -700,7 +782,7 @@ function InitialInfo() {
>
</Button>
<Button onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
<Button onClick={() => { createimage(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
</Button>
</Space>
@@ -1102,7 +1184,7 @@ function InitialInfo() {
<Button
type="primary"
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
onClick={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
onClick={() => { handleNextStep(step.id); }}
disabled={step.status !== 'completed'}
>
@@ -1127,7 +1209,7 @@ function InitialInfo() {
>
/
</Button>
<Button onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
<Button onClick={() => { createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
</Button>
</Space>
@@ -1387,6 +1469,62 @@ function InitialInfo() {
/>
</div>
</Modal>
{/* 图片/视频预览弹窗 */}
<Modal
open={previewVisible}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #8b5cf6 0%, #ddd6fe 100%)', borderRadius: 2 }} />
<span style={{ fontSize: 16, fontWeight: 700, color: '#8b5cf6', letterSpacing: 0.4 }}>
</span>
</div>
}
onCancel={handleClosePreview}
width={800}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(139, 92, 246, 0.08)' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
style={{ borderRadius: 10, background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)', border: 'none', boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' }}
>
</Button>
</div>
}
centered
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
},
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(139, 92, 246, 0.08)', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{previewType === 'image' ? (
<img
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={videoRef}
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
</React.Fragment>
);
}