dist/首页
This commit is contained in:
Vendored
+91
-91
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CxLKoDUI.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DSXie0ty.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
|
||||
import AdminConsume from './pages/AdminConsume';
|
||||
import AdminLoginPage from './pages/AdminLoginPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminPlatform from './pages/Adminplatform';
|
||||
import AdminPlatform from './pages/AdminPlatform';
|
||||
import AdminUsers from './pages/AdminUsers';
|
||||
import AdminModels from './pages/AdminModels';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
@@ -31,6 +31,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
|
||||
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||
import AdminContactRequests from './pages/AdminContactRequests';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -103,6 +104,7 @@ const App = () => {
|
||||
<Route path="authoriza" element={<AdminAuthoriz />} />
|
||||
<Route path="consume" element={<AdminConsume />} />
|
||||
<Route path="platform" element={<AdminPlatform />} />
|
||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt } from './crypto';
|
||||
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption for API request/response.
|
||||
* Uses Web Crypto API with a shared symmetric key.
|
||||
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
|
||||
*/
|
||||
|
||||
const ALGO = 'AES-GCM';
|
||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
||||
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return typeof window !== 'undefined' &&
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.subtle !== 'undefined';
|
||||
}
|
||||
|
||||
async function getCryptoKey(): Promise<CryptoKey> {
|
||||
if (cryptoKey) return cryptoKey;
|
||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
|
||||
}
|
||||
|
||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||
if (keyBytes.length !== 32) {
|
||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Encryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function decrypt(cipherB64: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Decryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const cipherBytes = combined.slice(IV_LENGTH);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
}
|
||||
@@ -431,7 +431,10 @@ export async function getOpenTypeList(params?: {
|
||||
page_size?: number;
|
||||
type_name?: string;
|
||||
open_type?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
}): Promise<{
|
||||
pagination: any;
|
||||
data: never[];
|
||||
}> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.page_size) q.set('page_size', String(params.page_size));
|
||||
@@ -467,6 +470,18 @@ export async function deleteOpenType(id: string): Promise<void> {
|
||||
await api.delete(`/open-type/delete/${id}`);
|
||||
}
|
||||
|
||||
export interface OpenTypeItem {
|
||||
id: string;
|
||||
openType: number;
|
||||
typeName: string;
|
||||
description: string;
|
||||
thumb?: string;
|
||||
}
|
||||
|
||||
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
|
||||
return api.get('/open-type/open_type_all');
|
||||
}
|
||||
|
||||
// ── Generation Records (Admin) ─────────────────────────────
|
||||
|
||||
export async function getAdminGenerationRecords(params?: {
|
||||
|
||||
@@ -2,20 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Card, Space, Table, Tag, Modal, Select, App, Input, Typography } from 'antd';
|
||||
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { getOAuthList, requestOAuth } from '../api';
|
||||
|
||||
const OPEN_TYPE_MAP: Record<number, string> = {
|
||||
1: '千川',
|
||||
2: '广告',
|
||||
3: '本地推',
|
||||
4: '星图',
|
||||
5: '快手代理商',
|
||||
6: '巨量星图',
|
||||
7: '巨量服务单',
|
||||
8: '腾讯服务单',
|
||||
9: '腾讯营销K2',
|
||||
10: '腾讯营销K3',
|
||||
};
|
||||
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
|
||||
|
||||
const PORT_TYPE_MAP: Record<number, string> = {
|
||||
1: '巨量',
|
||||
@@ -62,11 +49,31 @@ const AuthorizationPage: React.FC = () => {
|
||||
open_type: undefined as number | undefined,
|
||||
account_id: '',
|
||||
});
|
||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOAuthList();
|
||||
loadOpenTypeList();
|
||||
}, []);
|
||||
|
||||
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 (page = 1, pageSize = 10, params = searchParams) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
@@ -197,7 +204,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
title: '开户方式',
|
||||
dataIndex: 'openType',
|
||||
key: 'openType',
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{OPEN_TYPE_MAP[text] || text}</span>,
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台端口',
|
||||
@@ -225,16 +232,6 @@ const AuthorizationPage: React.FC = () => {
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: unknown, record: AuthorizationData) => (
|
||||
<Link to={`/consume?accountId=${record.id}`} style={{ color: '#6366f1' }}>
|
||||
查看消耗
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = authorizations.map((item, index) => ({
|
||||
@@ -245,7 +242,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<LockOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
@@ -279,10 +276,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={searchParams.open_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
|
||||
style={{ width: 140 }}
|
||||
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
|
||||
value: Number(key),
|
||||
label: value,
|
||||
}))}
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Input
|
||||
placeholder="账号ID"
|
||||
@@ -349,10 +343,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={selectedOpenType}
|
||||
onChange={(value) => setSelectedOpenType(value)}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
|
||||
value: Number(key),
|
||||
label: value,
|
||||
}))}
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ const ConsumePage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
loadColumns();
|
||||
loadData();
|
||||
}, [page, pageSize]);
|
||||
}, [page, pageSize, consumeDateRange]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
@@ -47,7 +47,6 @@ const ConsumePage: React.FC = () => {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
console.log(res);
|
||||
setConsumptionRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (e: any) {
|
||||
@@ -115,8 +114,8 @@ const ConsumePage: React.FC = () => {
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
{/* <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
|
||||
{/*<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
|
||||
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</Space>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty } from 'antd';
|
||||
import { CheckOutlined, DeleteOutlined, EyeOutlined, FilterOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { useAdminStore } from '../store';
|
||||
import { api } from '../api/client';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface ContactRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
industry: string;
|
||||
name: string;
|
||||
message: string | null;
|
||||
isHandled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const AdminContactRequests: React.FC = () => {
|
||||
const [data, setData] = useState<ContactRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [isHandledFilter, setIsHandledFilter] = useState<boolean | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<ContactRequest | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
|
||||
const { user } = useAdminStore();
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!user?.isAdmin) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(page));
|
||||
query.set('page_size', String(pageSize));
|
||||
if (isHandledFilter !== null) {
|
||||
query.set('is_handled', String(isHandledFilter));
|
||||
}
|
||||
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/contact/requests?${query.toString()}`);
|
||||
setData(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [page, pageSize, isHandledFilter]);
|
||||
|
||||
const handleMarkHandled = async (id: string) => {
|
||||
try {
|
||||
await api.put(`/contact/requests/${id}/handle`);
|
||||
message.success('已标记为处理');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/contact/requests/${id}`);
|
||||
message.success('已删除');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = (item: ContactRequest) => {
|
||||
setSelectedItem(item);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '公司名称',
|
||||
dataIndex: 'companyName',
|
||||
key: 'companyName',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '行业',
|
||||
dataIndex: 'industry',
|
||||
key: 'industry',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isHandled',
|
||||
key: 'isHandled',
|
||||
width: 80,
|
||||
render: (isHandled: boolean) => (
|
||||
<Tag color={isHandled ? 'green' : 'orange'}>
|
||||
{isHandled ? '已处理' : '待处理'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date: string) => formatDate(date),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_: unknown, record: ContactRequest) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{!record.isHandled && (
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkHandled(record.id)}>
|
||||
标记处理
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="确定删除该记录?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<MessageOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>联系请求管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条记录</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={isHandledFilter === null ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(null)}
|
||||
icon={<FilterOutlined />}
|
||||
size="small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={isHandledFilter === false ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(false)}
|
||||
size="small"
|
||||
>
|
||||
待处理
|
||||
</Button>
|
||||
<Button
|
||||
type={isHandledFilter === true ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(true)}
|
||||
size="small"
|
||||
>
|
||||
已处理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<Empty description="暂无联系请求" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />联系请求详情</Space>}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => setDetailModalOpen(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
{selectedItem && (
|
||||
<div style={{ padding: 8 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
{selectedItem.name}
|
||||
<Tag color={selectedItem.isHandled ? 'green' : 'orange'} style={{ marginLeft: 12 }}>
|
||||
{selectedItem.isHandled ? '已处理' : '待处理'}
|
||||
</Tag>
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#64748b' }}>手机号:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.phone}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>公司名称:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.companyName}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>行业:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.industry}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>提交时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(selectedItem.createdAt)}</Typography.Text>
|
||||
{selectedItem.message && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>留言:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.message}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{!selectedItem.isHandled && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
handleMarkHandled(selectedItem.id);
|
||||
setDetailModalOpen(false);
|
||||
}}
|
||||
icon={<CheckOutlined />}
|
||||
>
|
||||
标记为已处理
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setDetailModalOpen(false)}>关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminContactRequests;
|
||||
@@ -243,7 +243,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Text Credit Rate */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
||||
@@ -258,16 +258,18 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000}
|
||||
step={0.01}
|
||||
value={textRate}
|
||||
onChange={(v) => setTextRate(v || 0)}
|
||||
size="large"
|
||||
style={{ width: 160 }}
|
||||
addonAfter="积分"
|
||||
/>
|
||||
<Space.Compact style={{ width: 160 }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000}
|
||||
step={0.01}
|
||||
value={textRate}
|
||||
onChange={(v) => setTextRate(v || 0)}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text>积分</Typography.Text>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
||||
</Typography.Text>
|
||||
@@ -275,7 +277,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Generation Credit Ratios */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -764,7 +764,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -867,7 +867,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -81,7 +81,7 @@ const AdminLoginPage = () => {
|
||||
bottom: -100, left: -80, filter: 'blur(50px)',
|
||||
}} />
|
||||
|
||||
<Card bordered={false} style={{
|
||||
<Card variant="outlined" style={{
|
||||
width: 440,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
|
||||
|
||||
@@ -198,7 +198,7 @@ const AdminMenuConfig: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -157,7 +157,7 @@ const AdminModels: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
共 {models.length} 个模型配置,按权重进行加权随机调度
|
||||
|
||||
@@ -166,7 +166,7 @@ const AdminNotificationManager: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -68,7 +68,7 @@ const AdminOperationLogs: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
|
||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber, Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
|
||||
@@ -85,7 +85,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
{/* 通用设置:订单超时时间 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -100,14 +100,16 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
</div>
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="order_timeout" style={{ margin: 0 }}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={86400}
|
||||
placeholder="180"
|
||||
style={{ width: 140 }}
|
||||
size="middle"
|
||||
addonAfter="秒"
|
||||
/>
|
||||
<Space.Compact style={{ width: 140 }}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={86400}
|
||||
placeholder="180"
|
||||
style={{ width: '100%' }}
|
||||
size="middle"
|
||||
/>
|
||||
<Typography.Text>秒</Typography.Text>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -116,7 +118,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
{/* 模拟支付模式 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -139,7 +141,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
{/* 微信支付 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -216,7 +218,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
{/* 支付宝 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -171,7 +171,7 @@ const AdminPaymentStats: React.FC = () => {
|
||||
{/* Summary cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={stats.today.paidAmount}
|
||||
@@ -186,7 +186,7 @@ const AdminPaymentStats: React.FC = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="本月累计"
|
||||
value={monthInfo.paidAmount}
|
||||
@@ -203,7 +203,7 @@ const AdminPaymentStats: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
{/* Status breakdown */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
||||
<Row gutter={16}>
|
||||
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
|
||||
|
||||
@@ -177,7 +177,7 @@ const AdminRechargePackages: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -237,12 +237,12 @@ const AdminSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
|
||||
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
|
||||
@@ -283,7 +283,7 @@ const AdminUsers: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
@@ -337,7 +337,7 @@ const AdminUsers: React.FC = () => {
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
{/* Search bar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
|
||||
@@ -161,7 +161,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -10,8 +10,8 @@ import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface OpenType {
|
||||
id: string;
|
||||
open_type: number;
|
||||
type_name: string;
|
||||
openType: number;
|
||||
typeName: string;
|
||||
description: string;
|
||||
thumb?: string;
|
||||
createdAt: string;
|
||||
@@ -49,6 +49,8 @@ const AdminPlatform: React.FC = () => {
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [updateModalVisible, setUpdateModalVisible] = useState(false);
|
||||
const [currentOpenType, setCurrentOpenType] = useState<OpenType | null>(null);
|
||||
const [createThumbUrl, setCreateThumbUrl] = useState('');
|
||||
const [updateThumbUrl, setUpdateThumbUrl] = useState('');
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
const [updateForm] = Form.useForm();
|
||||
@@ -60,8 +62,8 @@ const AdminPlatform: React.FC = () => {
|
||||
page: p || page,
|
||||
page_size: ps || pageSize,
|
||||
});
|
||||
setOpenTypes(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
setOpenTypes(res.data || []);
|
||||
setTotal(res.pagination.total || 0);
|
||||
} catch {
|
||||
message.error('加载开户方式列表失败');
|
||||
} finally {
|
||||
@@ -76,16 +78,18 @@ const AdminPlatform: React.FC = () => {
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
console.log(values);
|
||||
const thumbUrl = createThumbUrl;
|
||||
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
|
||||
await createOpenType({
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: values.thumb,
|
||||
thumb: thumbPath,
|
||||
});
|
||||
message.success('创建成功');
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
setCreateThumbUrl('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '创建失败');
|
||||
@@ -95,7 +99,7 @@ const AdminPlatform: React.FC = () => {
|
||||
const handleDetail = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
setCurrentOpenType(openType);
|
||||
setCurrentOpenType(openType.data || {});
|
||||
setDetailModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
@@ -105,33 +109,34 @@ const AdminPlatform: React.FC = () => {
|
||||
const handleUpdate = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
setCurrentOpenType(openType);
|
||||
updateForm.setFieldsValue({
|
||||
open_type: openType.open_type,
|
||||
type_name: openType.type_name,
|
||||
description: openType.description,
|
||||
thumb: openType.thumb,
|
||||
});
|
||||
const thumb = openType.data?.thumb || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullThumbUrl = thumb.startsWith('http') ? thumb : `${baseUrl}${thumb}`;
|
||||
setCurrentOpenType(openType.data || {});
|
||||
setUpdateThumbUrl(fullThumbUrl);
|
||||
setUpdateModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!currentOpenType) return;
|
||||
try {
|
||||
const values = await updateForm.validateFields();
|
||||
const thumbUrl = updateThumbUrl;
|
||||
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
|
||||
await updateOpenType(currentOpenType.id, {
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: values.thumb,
|
||||
thumb: thumbPath,
|
||||
});
|
||||
message.success('更新成功');
|
||||
setUpdateModalVisible(false);
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
setUpdateThumbUrl('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
@@ -166,13 +171,13 @@ const AdminPlatform: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '开户类型',
|
||||
dataIndex: 'open_type',
|
||||
dataIndex: 'openType',
|
||||
width: 100,
|
||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型名称',
|
||||
dataIndex: 'type_name',
|
||||
dataIndex: 'typeName',
|
||||
width: 150,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
@@ -191,9 +196,12 @@ const AdminPlatform: React.FC = () => {
|
||||
title: '缩略图',
|
||||
dataIndex: 'thumb',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-'
|
||||
),
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = v.startsWith('http') ? v : `${baseUrl}${v}`;
|
||||
return <img src={fullUrl} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
@@ -217,7 +225,7 @@ const AdminPlatform: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
width: 240,
|
||||
render: (_: any, record: OpenType) => (
|
||||
<Space>
|
||||
<Button
|
||||
@@ -291,7 +299,9 @@ const AdminPlatform: React.FC = () => {
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
setCreateThumbUrl('');
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
@@ -326,28 +336,30 @@ const AdminPlatform: React.FC = () => {
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="thumb"
|
||||
label="缩略图"
|
||||
>
|
||||
<Form.Item label="缩略图">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={createThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: createThumbUrl }] : []}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
console.log(res);
|
||||
createForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess?.(res);
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
|
||||
setCreateThumbUrl(fullUrl);
|
||||
onSuccess?.({ url: fullUrl });
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
onRemove={() => setCreateThumbUrl('')}
|
||||
>
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
{!createThumbUrl && (
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -356,32 +368,68 @@ const AdminPlatform: React.FC = () => {
|
||||
<Modal
|
||||
title="开户方式详情"
|
||||
open={detailModalVisible}
|
||||
onOk={() => setDetailModalVisible(false)}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false);
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="关闭"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
width={520}
|
||||
>
|
||||
{currentOpenType && (
|
||||
<div style={{ lineHeight: '2' }}>
|
||||
<p><strong>ID:</strong> {currentOpenType.id}</p>
|
||||
<p><strong>开户类型:</strong> {currentOpenType.open_type}</p>
|
||||
<p><strong>类型名称:</strong> {currentOpenType.type_name}</p>
|
||||
<p><strong>描述:</strong> {currentOpenType.description}</p>
|
||||
<p>
|
||||
<strong>缩略图:</strong>{' '}
|
||||
{currentOpenType.thumb ? (
|
||||
<img
|
||||
src={currentOpenType.thumb}
|
||||
alt="thumb"
|
||||
style={{ width: 120, height: 80, objectFit: 'cover' }}
|
||||
/>
|
||||
) : '-'}
|
||||
</p>
|
||||
<p><strong>创建时间:</strong> {formatDate(currentOpenType.createdAt)}</p>
|
||||
<p><strong>更新时间:</strong> {formatDate(currentOpenType.updatedAt)}</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8fafc', borderRadius: 8 }}>
|
||||
<div style={{
|
||||
width: 80, height: 60, borderRadius: 6, overflow: 'hidden',
|
||||
background: currentOpenType.thumb ? 'transparent' : '#e2e8f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{currentOpenType.thumb ? (
|
||||
<img
|
||||
src={currentOpenType.thumb.startsWith('http') ? currentOpenType.thumb : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${currentOpenType.thumb}`}
|
||||
alt="thumb"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>暂无图片</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{currentOpenType.typeName}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>开户类型: {currentOpenType.openType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>ID</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.id}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>开户类型</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.openType}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>类型名称</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.typeName}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80 }}>描述</Typography.Text>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 6, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{currentOpenType.description || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>创建时间</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentOpenType.createdAt)}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>更新时间</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentOpenType.updatedAt)}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -395,6 +443,16 @@ const AdminPlatform: React.FC = () => {
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
afterOpenChange={(open) => {
|
||||
if (open && currentOpenType) {
|
||||
updateForm.setFieldsValue({
|
||||
open_type: currentOpenType.openType,
|
||||
type_name: currentOpenType.typeName,
|
||||
description: currentOpenType.description,
|
||||
});
|
||||
}
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="更新"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
@@ -424,29 +482,25 @@ const AdminPlatform: React.FC = () => {
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="thumb"
|
||||
label="缩略图"
|
||||
>
|
||||
<Form.Item label="缩略图">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
defaultFileList={
|
||||
currentOpenType?.thumb
|
||||
? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }]
|
||||
: []
|
||||
}
|
||||
fileList={updateThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: updateThumbUrl }] : []}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
updateForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess?.(res);
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
|
||||
setUpdateThumbUrl(fullUrl);
|
||||
onSuccess?.({ url: fullUrl });
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
onRemove={() => setUpdateThumbUrl('')}
|
||||
>
|
||||
{!updateForm.getFieldValue('thumb') && (
|
||||
{!updateThumbUrl && (
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
|
||||
@@ -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/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.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/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.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/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.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/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/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.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/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -17,14 +17,17 @@ from app.api.v1.image_engines import router as image_engines_router
|
||||
from app.api.v1.generation_ai import router as generation_ai_router
|
||||
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
|
||||
from app.api.v1.shot_replicate import router as shot_replicate_router
|
||||
from app.api.v1.recent_generation import router as recent_generation_router
|
||||
from app.api.v1.test import router as test_router
|
||||
from app.api.v1.user_oauth import router as user_oauth_router
|
||||
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
||||
from app.api.v1.user_oauth_account import router as user_oauth_account_router
|
||||
from app.api.v1.upload_material import router as upload_material_router
|
||||
from app.api.v1.pre_test_template import router as pre_test_template_router
|
||||
from app.api.v1.material_consumption import router as material_consumption_router
|
||||
from app.api.v1.open_type import router as open_type_router
|
||||
from app.api.v1.resources_material import router as resources_material_router
|
||||
from app.api.v1.contact import router as contact_router
|
||||
from app.api.admin import router as admin_module_router
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -45,12 +48,15 @@ api_router.include_router(image_engines_router)
|
||||
api_router.include_router(generation_ai_router)
|
||||
api_router.include_router(hot_opening_replicate_router)
|
||||
api_router.include_router(shot_replicate_router)
|
||||
api_router.include_router(recent_generation_router)
|
||||
api_router.include_router(test_router)
|
||||
api_router.include_router(user_oauth_router)
|
||||
api_router.include_router(user_oauth_app_router)
|
||||
api_router.include_router(user_oauth_account_router)
|
||||
api_router.include_router(upload_material_router)
|
||||
api_router.include_router(pre_test_template_router)
|
||||
api_router.include_router(material_consumption_router)
|
||||
api_router.include_router(open_type_router)
|
||||
api_router.include_router(resources_material_router)
|
||||
api_router.include_router(contact_router)
|
||||
api_router.include_router(admin_module_router)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.dependencies import (
|
||||
get_current_user_allow_password_pending,
|
||||
get_db,
|
||||
)
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
@@ -86,6 +87,20 @@ async def _get_register_credits(db: AsyncSession) -> int:
|
||||
return int(value) if value else 100
|
||||
|
||||
|
||||
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
|
||||
if credits <= 0:
|
||||
return
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
user_id=user.id,
|
||||
type="recharge",
|
||||
amount=credits,
|
||||
balance_after=user.credits,
|
||||
description=f"注册赠送 {credits} 积分",
|
||||
)
|
||||
db.add(record)
|
||||
|
||||
|
||||
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||
enabled_result = await db.execute(
|
||||
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
|
||||
@@ -108,6 +123,16 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||
return
|
||||
|
||||
user.credits += credits
|
||||
|
||||
record = CreditRecord(
|
||||
id=generate_id(),
|
||||
user_id=user.id,
|
||||
type="recharge",
|
||||
amount=credits,
|
||||
balance_after=user.credits,
|
||||
description=f"每日登录赠送 {credits} 积分",
|
||||
)
|
||||
db.add(record)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -204,6 +229,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
await _add_register_credit_record(db, user, register_credits)
|
||||
await _assign_default_frontend_menus(db, user)
|
||||
|
||||
user.credits = round(user.credits, 2)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import ContactRequestCreate, ContactRequestListOut, ContactRequestOut
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/contact", tags=["contact"])
|
||||
|
||||
|
||||
@router.post("/request", summary="提交联系请求", status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact_request(
|
||||
request: ContactRequestCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
count = await db.execute(
|
||||
select(func.count(ContactRequest.id))
|
||||
.where(ContactRequest.user_id == user.id)
|
||||
.where(ContactRequest.created_at >= today_start)
|
||||
.where(ContactRequest.created_at < today_end)
|
||||
)
|
||||
daily_count = count.scalar_one()
|
||||
|
||||
if daily_count >= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="每个账号每天只能提交一次联系我们"
|
||||
)
|
||||
|
||||
contact_request = ContactRequest(
|
||||
id=generate_id(),
|
||||
user_id=user.id,
|
||||
phone=request.phone,
|
||||
company_name=request.company_name,
|
||||
industry=request.industry,
|
||||
name=request.name,
|
||||
message=request.message,
|
||||
)
|
||||
|
||||
db.add(contact_request)
|
||||
await db.commit()
|
||||
await db.refresh(contact_request)
|
||||
|
||||
return {"message": "提交成功,我们会尽快与您联系"}
|
||||
|
||||
|
||||
@router.get("/requests", summary="获取联系请求列表", response_model=ContactRequestListOut)
|
||||
async def get_contact_requests(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_handled: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
query = select(ContactRequest).order_by(ContactRequest.created_at.desc())
|
||||
|
||||
if is_handled is not None:
|
||||
query = query.where(ContactRequest.is_handled == is_handled)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(query.offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
count_result = await db.execute(select(func.count(ContactRequest.id)))
|
||||
total = count_result.scalar_one()
|
||||
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}", summary="获取联系请求详情", response_model=ContactRequestOut)
|
||||
async def get_contact_request(
|
||||
request_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||
contact_request = result.scalar_one_or_none()
|
||||
|
||||
if not contact_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||
|
||||
return contact_request
|
||||
|
||||
|
||||
@router.put("/requests/{request_id}/handle", summary="标记为已处理")
|
||||
async def mark_as_handled(
|
||||
request_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||
contact_request = result.scalar_one_or_none()
|
||||
|
||||
if not contact_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||
|
||||
contact_request.is_handled = True
|
||||
await db.commit()
|
||||
await db.refresh(contact_request)
|
||||
|
||||
return {"message": "已标记为处理"}
|
||||
|
||||
|
||||
@router.delete("/requests/{request_id}", summary="删除联系请求")
|
||||
async def delete_contact_request(
|
||||
request_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||
contact_request = result.scalar_one_or_none()
|
||||
|
||||
if not contact_request:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||
|
||||
await db.delete(contact_request)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
@@ -43,6 +43,7 @@ from app.services.generation_billing_service import (
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
||||
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
||||
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
||||
from app.utils.id_gen import generate_id
|
||||
@@ -724,6 +725,7 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
|
||||
usage = data.get("usage", {})
|
||||
if usage:
|
||||
record.video_tokens_used = usage.get("total_tokens", 0)
|
||||
await sync_generation_record_media_token_snapshot(db, record, provider_response=data)
|
||||
# Log callback response
|
||||
from app.services.video_gen import _log_video_response
|
||||
_log_video_response(record.id, data)
|
||||
|
||||
@@ -25,7 +25,29 @@ async def public_list_menu_configs(
|
||||
)
|
||||
.order_by(MenuConfig.sort_order)
|
||||
)
|
||||
menus = result.scalars().all()
|
||||
all_menus = result.scalars().all()
|
||||
|
||||
# Filter menus based on user permissions
|
||||
# Show default menus + user-specific allowed menus (merged)
|
||||
user_allowed_paths = set(current_user.allowed_menus or [])
|
||||
|
||||
# Start with default menus
|
||||
allowed_ids = set()
|
||||
for menu in all_menus:
|
||||
if menu.is_default:
|
||||
allowed_ids.add(menu.id)
|
||||
if menu.parent_id:
|
||||
allowed_ids.add(menu.parent_id)
|
||||
|
||||
# Add user-specific allowed menus (merge with defaults)
|
||||
for menu in all_menus:
|
||||
if menu.path in user_allowed_paths:
|
||||
allowed_ids.add(menu.id)
|
||||
if menu.parent_id:
|
||||
allowed_ids.add(menu.parent_id)
|
||||
|
||||
menus = [m for m in all_menus if m.id in allowed_ids]
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id, "label": m.label, "path": m.path, "icon": m.icon,
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.notification import Notification
|
||||
from app.schemas.notification import NotificationOut, NotificationListOut, UnreadCountOut
|
||||
from app.schemas.notification import NotificationListOut, NotificationCreditsOut, UnreadCountOut
|
||||
from app.services.auth import decode_access_token
|
||||
from app.services.notification import (
|
||||
get_notifications,
|
||||
@@ -112,7 +112,8 @@ async def list_notifications(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await get_notifications(db, current_user.id, page, page_size, is_read)
|
||||
return {"items": items, "total": total}
|
||||
credits = NotificationCreditsOut(balance=round(float(current_user.credits or 0.0), 2))
|
||||
return {"items": items, "total": total, "credits": credits}
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.recent_generation import RecentGenerationModuleEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.recent_generation import RecentGenerationGroupOut
|
||||
from app.services.recent_generation_service import (
|
||||
DEFAULT_RECENT_GENERATION_LIMIT,
|
||||
MAX_RECENT_GENERATION_LIMIT,
|
||||
list_recent_generations,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/recent-generations",
|
||||
tags=["recent-generations"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=RecentGenerationGroupOut,
|
||||
summary="获取当前用户各模块最近生成记录",
|
||||
description=(
|
||||
"获取当前登录用户在多个生成模块下最近生成成功的图片/视频记录。"
|
||||
"返回结构固定为 project、chat_ai、hot_opening_replicate、shot_replicate 四个数组。"
|
||||
"modules 不传时查询全部模块;modules 可重复传参指定一个或多个模块,例如 "
|
||||
"?modules=project&modules=chat_ai。"
|
||||
"limit 表示每个模块最多返回多少条,默认 5 条,最大 100 条。"
|
||||
"接口只查询和返回展示所需轻量字段,不返回 prompt、engine_snapshot、provider_response_json 等大字段。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "查询成功,固定返回四个模块数组;没有数据的模块返回空数组。",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"project": [],
|
||||
"chat_ai": [],
|
||||
"hot_opening_replicate": [],
|
||||
"shot_replicate": [
|
||||
{
|
||||
"generated_time": "2026-06-26T14:30:00",
|
||||
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
|
||||
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
|
||||
"module": "shot_replicate",
|
||||
"shot_task_set_id": "0019ef0000000000001",
|
||||
"shot_segment_id": "0019ef0000000000002",
|
||||
"module_project_id": "0019ef0000000000003",
|
||||
"module_step_id": "0019ef0000000000004",
|
||||
"generation_id": "0019ef0000000000005",
|
||||
"resource_type": "video",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
401: {"description": "未登录或 Token 无效"},
|
||||
403: {"description": "账号需要先设置登录密码或无权限"},
|
||||
422: {"description": "参数校验失败,例如 limit 超出范围或 modules 枚举值非法"},
|
||||
},
|
||||
)
|
||||
async def get_recent_generations(
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
ge=1,
|
||||
le=MAX_RECENT_GENERATION_LIMIT,
|
||||
description=(
|
||||
"每个模块返回的最近生成记录数量,默认 5,最大 100。"
|
||||
"例如 limit=10 表示 project/chat_ai/hot_opening_replicate/shot_replicate 每个模块最多返回 10 条。"
|
||||
),
|
||||
examples=[DEFAULT_RECENT_GENERATION_LIMIT],
|
||||
),
|
||||
] = DEFAULT_RECENT_GENERATION_LIMIT,
|
||||
modules: Annotated[
|
||||
list[RecentGenerationModuleEnum] | None,
|
||||
Query(
|
||||
description=(
|
||||
"模块枚举,可不传或重复传参。"
|
||||
"不传表示查询全部模块。"
|
||||
"可选值:"
|
||||
"project=项目生成 GenerationRecord;"
|
||||
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async;"
|
||||
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate;"
|
||||
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
|
||||
),
|
||||
examples=[["project", "chat_ai"]],
|
||||
),
|
||||
] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RecentGenerationGroupOut:
|
||||
return await list_recent_generations(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
modules=modules,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -16,6 +16,8 @@ from app.models.user import User
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
from app.models.upload_task import UploadTask
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.services.upload_material_service import upload_material_to_platform
|
||||
from app.services.upload_queue import upload_queue
|
||||
from app.services.upload_queue import upload_queue
|
||||
@@ -44,6 +46,30 @@ class UpdateFileName(BaseModel):
|
||||
class FileNameUpdateRequest(BaseModel):
|
||||
filenames: list[UpdateFileName] = Field(..., description="批量修改文件名列表,格式: [{\"source_id\":\"资源id\",\"file_name\":\"文件名称\"}]")
|
||||
|
||||
# @router.post(
|
||||
# "/batch-upload",
|
||||
# summary="批量上传素材到平台",
|
||||
# description="支持批量上传多个授权账户下的资源到素材库,预留下前测功能",
|
||||
# )
|
||||
# async def batch_upload_material(
|
||||
# current_user: User = Depends(get_current_user),
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# ) -> Any | dict:
|
||||
# try:
|
||||
# result = await upload_material_to_platform(
|
||||
# ["0019ef7700c503991c9"],
|
||||
# ["1856633793022992"],
|
||||
# "0019f018b4b3612e184",
|
||||
# db,
|
||||
# current_user.id,
|
||||
# None,
|
||||
# )
|
||||
# return result
|
||||
# except Exception as e:
|
||||
# return {
|
||||
# "code": 0,
|
||||
# "message": str(e),
|
||||
# }
|
||||
|
||||
@router.post(
|
||||
"/async-batch-upload",
|
||||
@@ -55,143 +81,199 @@ async def async_batch_upload_material(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any | dict:
|
||||
if not req.tasks:
|
||||
try:
|
||||
if not req.tasks:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "上传任务列表不能为空",
|
||||
"task_ids": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
task_ids = []
|
||||
errors = []
|
||||
|
||||
source_model_map = {
|
||||
"generation_records": "GenerationRecord",
|
||||
"generated_resources": None,
|
||||
"chat_generation_tasks": "ChatGenerationTask",
|
||||
}
|
||||
|
||||
for task_index, task in enumerate(req.tasks, 1):
|
||||
if not task.advertiser_ids:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "广告主id数组不能为空",
|
||||
})
|
||||
continue
|
||||
|
||||
if not task.resource_ids:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "资源id数组不能为空",
|
||||
})
|
||||
continue
|
||||
|
||||
if task.is_pre_test == "1" and not task.pre_test_template:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "开启前测功能时,必须指定前测模板id",
|
||||
})
|
||||
continue
|
||||
|
||||
if task.is_pre_test == "1":
|
||||
template = await db.execute(
|
||||
select(PreTestTemplate).where(PreTestTemplate.id == task.pre_test_template).
|
||||
where(PreTestTemplate.deleted_at.is_(None)).
|
||||
where(PreTestTemplate.user_id == current_user.id)
|
||||
)
|
||||
template = template.scalar_one_or_none()
|
||||
if not template:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "前测模板id不存在",
|
||||
})
|
||||
continue
|
||||
|
||||
target_source_model = source_model_map.get(task.source_model)
|
||||
|
||||
# 检查资源id是否存在,非资源id
|
||||
if target_source_model:
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
.where(GeneratedResource.source_model == target_source_model)
|
||||
.where(GeneratedResource.source_id.in_(task.resource_ids))
|
||||
.where(GeneratedResource.user_id == current_user.id)
|
||||
.where(GeneratedResource.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
valid_resource_ids = [row[0] for row in result.all()]
|
||||
|
||||
invalid_ids = set(task.resource_ids) - set(valid_resource_ids)
|
||||
|
||||
if invalid_ids:
|
||||
invalid_ids_str = ", ".join(invalid_ids)
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": f"资源id [{invalid_ids_str}] 不可用或已删除",
|
||||
})
|
||||
continue
|
||||
|
||||
resource_ids_to_upload = valid_resource_ids
|
||||
else:
|
||||
#用户提交的直接是资源id
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
.where(GeneratedResource.id.in_(task.resource_ids))
|
||||
.where(GeneratedResource.user_id == current_user.id)
|
||||
.where(GeneratedResource.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
valid_resource_ids = [row[0] for row in result.all()]
|
||||
|
||||
invalid_ids = set(task.resource_ids) - set(valid_resource_ids)
|
||||
|
||||
if invalid_ids:
|
||||
invalid_ids_str = ", ".join(invalid_ids)
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": f"资源id [{invalid_ids_str}] 不可用或已删除",
|
||||
})
|
||||
continue
|
||||
|
||||
resource_ids_to_upload = valid_resource_ids
|
||||
|
||||
for advertiser_id in task.advertiser_ids:
|
||||
for resource_id in resource_ids_to_upload:
|
||||
other_info = {}
|
||||
if task.is_pre_test == "1":
|
||||
other_info["is_pre_test"] = task.is_pre_test
|
||||
other_info["pre_test_template"] = task.pre_test_template
|
||||
|
||||
task_id = generate_id()
|
||||
upload_task = UploadTask(
|
||||
id=task_id,
|
||||
user_id=current_user.id,
|
||||
oauth_id=task.oauth_id,
|
||||
advertiser_id=advertiser_id,
|
||||
resource_id=resource_id,
|
||||
status=1,
|
||||
note=None,
|
||||
other_info=json.dumps(other_info) if other_info else None,
|
||||
)
|
||||
|
||||
db.add(upload_task)
|
||||
await upload_queue.enqueue(task_id)
|
||||
task_ids.append(task_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
message = "上传任务已提交"
|
||||
if errors:
|
||||
message = f"部分任务提交成功,{len(errors)} 个任务失败"
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "上传任务列表不能为空",
|
||||
"message": message,
|
||||
"task_ids": task_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 1,
|
||||
"message": str(e),
|
||||
"task_ids": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
task_ids = []
|
||||
errors = []
|
||||
|
||||
source_model_map = {
|
||||
"generation_records": "GenerationRecord",
|
||||
"generated_resources": None,
|
||||
"chat_generation_tasks": "ChatGenerationTask",
|
||||
}
|
||||
|
||||
for task_index, task in enumerate(req.tasks, 1):
|
||||
if not task.advertiser_ids:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "广告主id数组不能为空",
|
||||
})
|
||||
continue
|
||||
|
||||
if not task.resource_ids:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "资源id数组不能为空",
|
||||
})
|
||||
continue
|
||||
|
||||
if task.is_pre_test == "1" and not task.pre_test_template:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "开启前测功能时,必须指定前测模板id",
|
||||
})
|
||||
continue
|
||||
|
||||
if task.is_pre_test == "1":
|
||||
template = await db.execute(
|
||||
select(PreTestTemplate).where(PreTestTemplate.id == task.pre_test_template).
|
||||
where(PreTestTemplate.deleted_at.is_(None)).
|
||||
where(PreTestTemplate.user_id == current_user.id)
|
||||
@router.post(
|
||||
"/oauth_account_list",
|
||||
summary="获取授权账户列表",
|
||||
description="获取用户授权账户列表",
|
||||
)
|
||||
async def async_get_oauth_account_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any | dict:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
UserOAuthAccount.advertiser_id,
|
||||
UserOAuthAccount.advertiser_name,
|
||||
UserOAuthAccount.oauth_id,
|
||||
UserOAuth.open_type,
|
||||
).join(
|
||||
UserOAuth,
|
||||
UserOAuth.id == UserOAuthAccount.oauth_id
|
||||
).where(
|
||||
UserOAuth.user_id == current_user.id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
UserOAuthAccount.deleted_at.is_(None),
|
||||
)
|
||||
template = template.scalar_one_or_none()
|
||||
if not template:
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": "前测模板id不存在",
|
||||
})
|
||||
continue
|
||||
)
|
||||
|
||||
target_source_model = source_model_map.get(task.source_model)
|
||||
accounts = result.all()
|
||||
|
||||
# 检查资源id是否存在,非资源id
|
||||
if target_source_model:
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
.where(GeneratedResource.source_model == target_source_model)
|
||||
.where(GeneratedResource.source_id.in_(task.resource_ids))
|
||||
.where(GeneratedResource.user_id == current_user.id)
|
||||
.where(GeneratedResource.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
valid_resource_ids = [row[0] for row in result.all()]
|
||||
|
||||
invalid_ids = set(task.resource_ids) - set(valid_resource_ids)
|
||||
|
||||
if invalid_ids:
|
||||
invalid_ids_str = ", ".join(invalid_ids)
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": f"资源id [{invalid_ids_str}] 不可用或已删除",
|
||||
})
|
||||
continue
|
||||
|
||||
resource_ids_to_upload = valid_resource_ids
|
||||
else:
|
||||
#用户提交的直接是资源id
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
.where(GeneratedResource.id.in_(task.resource_ids))
|
||||
.where(GeneratedResource.user_id == current_user.id)
|
||||
.where(GeneratedResource.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(query)
|
||||
valid_resource_ids = [row[0] for row in result.all()]
|
||||
|
||||
invalid_ids = set(task.resource_ids) - set(valid_resource_ids)
|
||||
|
||||
if invalid_ids:
|
||||
invalid_ids_str = ", ".join(invalid_ids)
|
||||
errors.append({
|
||||
"task_index": task_index,
|
||||
"error": f"资源id [{invalid_ids_str}] 不可用或已删除",
|
||||
})
|
||||
continue
|
||||
|
||||
resource_ids_to_upload = valid_resource_ids
|
||||
|
||||
for advertiser_id in task.advertiser_ids:
|
||||
for resource_id in resource_ids_to_upload:
|
||||
other_info = {}
|
||||
if task.is_pre_test == "1":
|
||||
other_info["is_pre_test"] = task.is_pre_test
|
||||
other_info["pre_test_template"] = task.pre_test_template
|
||||
|
||||
task_id = generate_id()
|
||||
upload_task = UploadTask(
|
||||
id=task_id,
|
||||
user_id=current_user.id,
|
||||
oauth_id=task.oauth_id,
|
||||
advertiser_id=advertiser_id,
|
||||
resource_id=resource_id,
|
||||
status=1,
|
||||
note=None,
|
||||
other_info=json.dumps(other_info) if other_info else None,
|
||||
)
|
||||
|
||||
db.add(upload_task)
|
||||
await upload_queue.enqueue(task_id)
|
||||
task_ids.append(task_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
message = "上传任务已提交"
|
||||
if errors:
|
||||
message = f"部分任务提交成功,{len(errors)} 个任务失败"
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": message,
|
||||
"task_ids": task_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "查询成功",
|
||||
"data": [
|
||||
{
|
||||
"advertiser_id": account.advertiser_id,
|
||||
"advertiser_name": account.advertiser_name,
|
||||
"oauth_id": account.oauth_id,
|
||||
"open_type": account.open_type,
|
||||
}
|
||||
for account in accounts
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 1,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
@router.post(
|
||||
"/batch-update-filename",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user_oauth_account import (
|
||||
OAuthAccountListResponse,
|
||||
DeleteOAuthAccountRequest,
|
||||
)
|
||||
from app.services.user_oauth_account_service import (
|
||||
get_oauth_account_list,
|
||||
delete_oauth_account,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/oauth-account", tags=["oauth-account"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
summary="获取授权账户列表",
|
||||
description="获取授权账户列表,支持按广告主ID、授权ID、广告账户名称筛选",
|
||||
)
|
||||
async def oauth_account_list(
|
||||
advertiser_id: str | None = Query(None, description="广告主账户ID"),
|
||||
oauth_id: str | None = Query(None, description="授权ID"),
|
||||
advertiser_name: str | None = Query(None, description="广告账户名称(模糊查询)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
获取授权账户列表
|
||||
|
||||
- **advertiser_id**: 广告主账户ID(可选)
|
||||
- **oauth_id**: 授权ID(可选)
|
||||
- **advertiser_name**: 广告账户名称,支持模糊查询(可选)
|
||||
- **page**: 页码,默认为1
|
||||
- **page_size**: 每页数量,默认为10,最大100
|
||||
"""
|
||||
try:
|
||||
result = await get_oauth_account_list(
|
||||
db=db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
advertiser_id=advertiser_id,
|
||||
oauth_id=oauth_id,
|
||||
advertiser_name=advertiser_name,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "查询成功",
|
||||
"data": result["data"],
|
||||
"pagination": {
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total": result["total"],
|
||||
"total_pages": result["total_pages"],
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/delete",
|
||||
summary="删除授权账户",
|
||||
description="软删除授权账户",
|
||||
)
|
||||
async def delete_oauth_account_api(
|
||||
id: str = Query(..., description="授权账户表id"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
await delete_oauth_account(
|
||||
db=db,
|
||||
account_id=id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "删除成功",
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
)
|
||||
+29
-11
@@ -113,8 +113,8 @@ class Settings(BaseSettings):
|
||||
PROVIDER_LIMIT_TOKEN_TTL_SECONDS: int = 600
|
||||
|
||||
CELERY_DB_POOL_SIZE: int = 1
|
||||
CELERY_DB_MAX_OVERFLOW: int = 1
|
||||
CELERY_DB_POOL_TIMEOUT: int = 30
|
||||
CELERY_DB_MAX_OVERFLOW: int = 2
|
||||
CELERY_DB_POOL_TIMEOUT: int = 60
|
||||
CELERY_DB_POOL_RECYCLE: int = 1800
|
||||
|
||||
# Celery 图片/视频下载容灾配置。
|
||||
@@ -125,30 +125,48 @@ class Settings(BaseSettings):
|
||||
DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS: int = 30
|
||||
DOWNLOAD_TASK_LEASE_SECONDS: int = 10 * 60
|
||||
DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
||||
DOWNLOAD_RECOVERY_BATCH_SIZE: int = 100
|
||||
DOWNLOAD_RECOVERY_BATCH_SIZE: int = 20
|
||||
DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS: int = 3
|
||||
# 下载恢复自循环:不依赖 Celery beat,不新增 worker;由 gen_result_download 队列周期扫描 DB/Redis。
|
||||
DOWNLOAD_RECOVERY_LOOP_ENABLED: bool = False
|
||||
DOWNLOAD_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||
DOWNLOAD_RECOVERY_LOOP_LOCK_KEY: str = "vg:celery:download_recovery_loop_lock"
|
||||
DOWNLOAD_RECOVERY_LOOP_LOCK_TTL_SECONDS: int = 55
|
||||
DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS: int = 1
|
||||
DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS: bool = True
|
||||
DOWNLOAD_EVENT_VERBOSE_ENABLED: bool = True
|
||||
MEDIA_TOKEN_SNAPSHOT_ENABLED: bool = True
|
||||
DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active"
|
||||
DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index"
|
||||
|
||||
# Celery 生成链路 / provider poll 容灾配置。
|
||||
# 说明:
|
||||
# - 不新增 Celery worker;恢复任务仍投递到 gen_result_download。
|
||||
# - worker_ready 每个 worker 都会尝试抢启动恢复锁,只有抢到锁的 worker 投递恢复任务。
|
||||
# - 启动容灾保留,但恢复扫描独立投递到 CELERY_RECOVERY_QUEUE。
|
||||
# - worker_ready 每个 worker 都会尝试抢启动恢复锁,只有抢到锁的 worker 投递恢复协调任务。
|
||||
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
|
||||
GENERATION_RECOVERY_BATCH_SIZE: int = 100
|
||||
GENERATION_RECOVERY_MAX_ROUNDS: int = 5
|
||||
POLL_RECOVERY_BATCH_SIZE: int = 100
|
||||
GENERATION_RECOVERY_BATCH_SIZE: int = 20
|
||||
GENERATION_RECOVERY_MAX_ROUNDS: int = 1
|
||||
POLL_RECOVERY_BATCH_SIZE: int = 20
|
||||
POLL_TASK_LEASE_SECONDS: int = 5 * 60
|
||||
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
|
||||
POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:poll:active"
|
||||
POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:poll:active_index"
|
||||
CELERY_STARTUP_RECOVERY_LOCK_KEY: str = "vg:celery:startup_recovery_lock"
|
||||
CELERY_STARTUP_RECOVERY_LOCK_TTL_SECONDS: int = 120
|
||||
CELERY_RECOVERY_QUEUE: str = "gen_recovery"
|
||||
CELERY_RECOVERY_TASK_LOCK_TTL_SECONDS: int = 10 * 60
|
||||
CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS: int = 300
|
||||
CELERY_RECOVERY_TIME_LIMIT_SECONDS: int = 420
|
||||
CELERY_RECOVERY_STARTUP_TASK_LOCK_KEY: str = "vg:celery:startup_recovery_task_lock"
|
||||
GENERATION_RECOVERY_LOCK_KEY: str = "vg:celery:generation_recovery_lock"
|
||||
DOWNLOAD_RECOVERY_LOCK_KEY: str = "vg:celery:download_recovery_lock"
|
||||
MODULE_ASYNC_RECOVERY_LOCK_KEY: str = "vg:celery:module_async_recovery_lock"
|
||||
SHOT_SPLIT_RECOVERY_LOCK_KEY: str = "vg:celery:shot_split_recovery_lock"
|
||||
|
||||
# 模块异步任务容灾配置。
|
||||
# 覆盖 ModuleGenerationStep 提词任务、shot 原视频/片段分析、shot ffmpeg 切割 active 注册。
|
||||
# 不新增 worker 队列:恢复扫描仍走 gen_result_download,真实业务任务回到原始队列。
|
||||
MODULE_ASYNC_RECOVERY_BATCH_SIZE: int = 100
|
||||
# 恢复扫描走 CELERY_RECOVERY_QUEUE,真实业务任务回到原始队列。
|
||||
MODULE_ASYNC_RECOVERY_BATCH_SIZE: int = 20
|
||||
MODULE_ASYNC_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
||||
MODULE_ASYNC_LEASE_SECONDS: int = 10 * 60
|
||||
MODULE_ASYNC_LOCK_TTL_SECONDS: int = 10 * 60
|
||||
@@ -192,7 +210,7 @@ class Settings(BaseSettings):
|
||||
SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30
|
||||
SHOT_SPLIT_LEASE_SECONDS: int = 10 * 60
|
||||
SHOT_SPLIT_PENDING_TIMEOUT_SECONDS: int = 5 * 60
|
||||
SHOT_SPLIT_RECOVERY_BATCH_SIZE: int = 50
|
||||
SHOT_SPLIT_RECOVERY_BATCH_SIZE: int = 20
|
||||
SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock"
|
||||
SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore"
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ from app.enums.video_prompt_schema import *
|
||||
from app.enums.user import *
|
||||
from app.enums.credit_record import *
|
||||
from app.enums.token_usage import *
|
||||
from app.enums.generation_task import *
|
||||
from app.enums.generation_status import *
|
||||
from app.enums.sms import *
|
||||
from app.enums.notification import *
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GenerationMode(str, Enum):
|
||||
"""生成模式。"""
|
||||
STANDARD = "standard"
|
||||
FAST = "fast"
|
||||
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
"""生成类型。"""
|
||||
video = "video"
|
||||
image = "image"
|
||||
|
||||
|
||||
class ChatGenerationTaskStatus(str, Enum):
|
||||
"""聊天生成任务状态。"""
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ChatGenerationPipelineStage(str, Enum):
|
||||
"""聊天生成任务阶段。"""
|
||||
waiting = "waiting"
|
||||
prompt_optimization = "prompt_optimization"
|
||||
video_generation = "video_generation"
|
||||
post_processing = "post_processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ChatGenerationTaskEventType(str, Enum):
|
||||
"""聊天生成任务事件类型。"""
|
||||
TASK_CREATED = "TASK_CREATED"
|
||||
TASK_DELETED = "TASK_DELETED"
|
||||
TASK_CANCELLED = "TASK_CANCELLED"
|
||||
PROMPT_OPT_STARTED = "PROMPT_OPT_STARTED"
|
||||
PROMPT_OPT_COMPLETED = "PROMPT_OPT_COMPLETED"
|
||||
PROMPT_OPT_FAILED = "PROMPT_OPT_FAILED"
|
||||
VIDEO_GEN_STARTED = "VIDEO_GEN_STARTED"
|
||||
VIDEO_GEN_COMPLETED = "VIDEO_GEN_COMPLETED"
|
||||
VIDEO_GEN_FAILED = "VIDEO_GEN_FAILED"
|
||||
POST_PROCESSING_STARTED = "POST_PROCESSING_STARTED"
|
||||
POST_PROCESSING_COMPLETED = "POST_PROCESSING_COMPLETED"
|
||||
POST_PROCESSING_FAILED = "POST_PROCESSING_FAILED"
|
||||
TASK_COMPLETED = "TASK_COMPLETED"
|
||||
TASK_FAILED = "TASK_FAILED"
|
||||
@@ -0,0 +1,22 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GenerationStatus(str, Enum):
|
||||
"""生成状态。"""
|
||||
prompt_optimized = "prompt_optimized"
|
||||
generating = "generating"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
"""生成类型。"""
|
||||
video = "video"
|
||||
image = "image"
|
||||
|
||||
|
||||
# 生成配置常量
|
||||
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||
IMAGE_SIZES = ["2K", "4K"]
|
||||
@@ -0,0 +1,101 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GenerationMode(str, Enum):
|
||||
CHATAPI_ASYNC = "chatapi_async"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class ChatGenerationTaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
GENERATING = "generating"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ChatGenerationPipelineStage(str, Enum):
|
||||
QUEUED = "queued"
|
||||
PREPARING = "preparing"
|
||||
CREATING_PROVIDER_TASK = "creating_provider_task"
|
||||
WAITING_REMOTE = "waiting_remote"
|
||||
POLLING = "polling"
|
||||
RESULT_READY = "result_ready"
|
||||
DOWNLOAD_QUEUED = "download_queued"
|
||||
DOWNLOADING = "downloading"
|
||||
RETRY_WAITING = "retry_waiting"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
TIMEOUT = "timeout"
|
||||
DOWNLOAD_FAILED = "download_failed"
|
||||
|
||||
|
||||
class ChatGenerationTaskEventType(str, Enum):
|
||||
PROMPT_CONCAT_START = "PROMPT_CONCAT_START"
|
||||
PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS"
|
||||
|
||||
PROVIDER_CREATE_START = "PROVIDER_CREATE_START"
|
||||
PROVIDER_CREATE_SUCCESS = "PROVIDER_CREATE_SUCCESS"
|
||||
PROVIDER_CREATE_FAILED = "PROVIDER_CREATE_FAILED"
|
||||
|
||||
POLL_START = "POLL_START"
|
||||
POLL_PENDING = "POLL_PENDING"
|
||||
POLL_SUCCESS = "POLL_SUCCESS"
|
||||
POLL_FAILED = "POLL_FAILED"
|
||||
POLL_SUCCESS_AFTER_TIMEOUT_RECOVERY = "POLL_SUCCESS_AFTER_TIMEOUT_RECOVERY"
|
||||
FINAL_POLL_BEFORE_TIMEOUT_ERROR = "FINAL_POLL_BEFORE_TIMEOUT_ERROR"
|
||||
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
|
||||
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
|
||||
|
||||
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
|
||||
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
|
||||
DOWNLOAD_START = "DOWNLOAD_START"
|
||||
DOWNLOAD_SUCCESS = "DOWNLOAD_SUCCESS"
|
||||
DOWNLOAD_RETRY_WAITING = "DOWNLOAD_RETRY_WAITING"
|
||||
DOWNLOAD_RETRY_ENQUEUE = "DOWNLOAD_RETRY_ENQUEUE"
|
||||
DOWNLOAD_RETRY_ENQUEUE_FAILED = "DOWNLOAD_RETRY_ENQUEUE_FAILED"
|
||||
DOWNLOAD_RETRY_NOT_DUE = "DOWNLOAD_RETRY_NOT_DUE"
|
||||
DOWNLOAD_STUCK_RECOVER = "DOWNLOAD_STUCK_RECOVER"
|
||||
DOWNLOAD_RECOVERY_ENQUEUE = "DOWNLOAD_RECOVERY_ENQUEUE"
|
||||
DOWNLOAD_RECOVERY_ENQUEUE_FAILED = "DOWNLOAD_RECOVERY_ENQUEUE_FAILED"
|
||||
DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
|
||||
DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE"
|
||||
|
||||
DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING"
|
||||
DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE"
|
||||
DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING"
|
||||
DOWNLOAD_SKIP_ALREADY_COMPLETED = "DOWNLOAD_SKIP_ALREADY_COMPLETED"
|
||||
DOWNLOAD_SKIP_NO_REMOTE_RESULT_URL = "DOWNLOAD_SKIP_NO_REMOTE_RESULT_URL"
|
||||
DOWNLOAD_SKIP_STAGE_NOT_ALLOWED = "DOWNLOAD_SKIP_STAGE_NOT_ALLOWED"
|
||||
DOWNLOAD_SKIP_DOWNLOADING_LEASE_ALIVE = "DOWNLOAD_SKIP_DOWNLOADING_LEASE_ALIVE"
|
||||
DOWNLOAD_SKIP_RETRY_NOT_DUE = "DOWNLOAD_SKIP_RETRY_NOT_DUE"
|
||||
DOWNLOAD_SKIP_FINAL_STATE = "DOWNLOAD_SKIP_FINAL_STATE"
|
||||
DOWNLOAD_SKIP_DISABLED = "DOWNLOAD_SKIP_DISABLED"
|
||||
|
||||
TASK_TIMEOUT = "TASK_TIMEOUT"
|
||||
|
||||
|
||||
ALLOWED_GENERATION_MODES = {
|
||||
GenerationMode.CHATAPI_ASYNC.value,
|
||||
GenerationMode.HOT_OPENING_REPLICATE.value,
|
||||
GenerationMode.SHOT_REPLICATE.value,
|
||||
}
|
||||
|
||||
FINAL_CHAT_GENERATION_STAGES = {
|
||||
ChatGenerationPipelineStage.DONE.value,
|
||||
ChatGenerationPipelineStage.FAILED.value,
|
||||
ChatGenerationPipelineStage.TIMEOUT.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
||||
}
|
||||
|
||||
DOWNLOAD_RECOVERABLE_STAGES = {
|
||||
ChatGenerationPipelineStage.RESULT_READY.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
|
||||
ChatGenerationPipelineStage.DOWNLOADING.value,
|
||||
ChatGenerationPipelineStage.RETRY_WAITING.value,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
"""通知类型。"""
|
||||
|
||||
SYSTEM = "system"
|
||||
CREDIT = "credit"
|
||||
VIDEO = "video"
|
||||
GENERATION = "generation"
|
||||
PAYMENT = "payment"
|
||||
RECHARGE = "recharge"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
NOTIFICATION_TYPE_LABELS = {
|
||||
NotificationType.SYSTEM.value: "系统通知",
|
||||
NotificationType.CREDIT.value: "积分通知",
|
||||
NotificationType.VIDEO.value: "视频通知",
|
||||
NotificationType.GENERATION.value: "生成通知",
|
||||
NotificationType.PAYMENT.value: "支付通知",
|
||||
NotificationType.RECHARGE.value: "充值通知",
|
||||
NotificationType.UNKNOWN.value: "未知通知",
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
|
||||
|
||||
|
||||
class RecentGenerationModuleEnum(StrEnum):
|
||||
"""最近生成记录接口支持的模块分组枚举。"""
|
||||
|
||||
PROJECT = "project"
|
||||
CHAT_AI = "chat_ai"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
|
||||
|
||||
class RecentGenerationResourceTypeEnum(StrEnum):
|
||||
"""最近生成记录接口返回的资源类型枚举。"""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
RECENT_GENERATION_ALL_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
|
||||
RecentGenerationModuleEnum.PROJECT,
|
||||
RecentGenerationModuleEnum.CHAT_AI,
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE,
|
||||
)
|
||||
"""最近生成记录接口默认查询的全部模块。"""
|
||||
|
||||
|
||||
RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
|
||||
RecentGenerationModuleEnum.CHAT_AI,
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE,
|
||||
)
|
||||
"""来自 chat_generation_tasks 表的模块集合。"""
|
||||
|
||||
|
||||
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
|
||||
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC,
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
|
||||
}
|
||||
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。"""
|
||||
|
||||
|
||||
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
|
||||
task_mode.value: module
|
||||
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items()
|
||||
}
|
||||
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
|
||||
|
||||
|
||||
RECENT_GENERATION_COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
|
||||
"""最近生成记录只展示生成成功的数据,状态值与 ChatGenerationTaskStatus.COMPLETED 保持一致。"""
|
||||
@@ -0,0 +1,9 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SmsScene(str, Enum):
|
||||
"""短信场景。"""
|
||||
register = "register"
|
||||
login = "login"
|
||||
common = "common"
|
||||
set_password = "set_password"
|
||||
+60
-113
@@ -125,7 +125,7 @@ async def _seed_data():
|
||||
from app.models.model_config import ModelConfig
|
||||
from app.services.auth import hash_password
|
||||
from app.utils.id_gen import generate_id
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
|
||||
async with async_session() as db:
|
||||
# Check if admin exists
|
||||
@@ -354,91 +354,42 @@ async def _seed_data():
|
||||
# Seed menu configs
|
||||
from app.models.menu_config import MenuConfig
|
||||
|
||||
frontend_groups = [
|
||||
("AI项目行业生成", "HomeOutlined", 0),
|
||||
("AI对话生成", "HomeOutlined", 1),
|
||||
("AI视频创作", "HomeOutlined", 2),
|
||||
("资产管理", "HomeOutlined", 3),
|
||||
("媒体关联", "HomeOutlined", 4),
|
||||
]
|
||||
frontend_group_ids: dict[str, str] = {}
|
||||
for label, icon, order in frontend_groups:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.label == label,
|
||||
MenuConfig.menu_type == "group",
|
||||
MenuConfig.menu_target == "frontend",
|
||||
).limit(1)
|
||||
)
|
||||
group = existing.scalar_one_or_none()
|
||||
if group:
|
||||
frontend_group_ids[label] = group.id
|
||||
else:
|
||||
gid = generate_id()
|
||||
frontend_group_ids[label] = gid
|
||||
db.add(
|
||||
MenuConfig(
|
||||
id=gid,
|
||||
path="",
|
||||
label=label,
|
||||
icon=icon,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
parent_id=None,
|
||||
menu_type="group",
|
||||
menu_target="frontend",
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
menu_count = await db.execute(select(func.count(MenuConfig.id)))
|
||||
menu_count_result = menu_count.scalar_one()
|
||||
|
||||
logging.info(f"Menu config count: {menu_count_result}")
|
||||
|
||||
if menu_count_result == 0:
|
||||
logging.info("Inserting default menu configs...")
|
||||
frontend_menus = [
|
||||
{"id": "0019eca2549ba477069", "label": "制作素材", "path": "", "icon": "PlayCircleOutlined", "sort_order": 1, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01914a66279ec0", "label": "灵感参考", "path": "", "icon": "HomeOutlined", "sort_order": 2, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019eca2735b9f3d944", "label": "我的资产", "path": "", "icon": "HomeOutlined", "sort_order": 3, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019eca27ec59922048", "label": "广告素材管理", "path": "", "icon": "HomeOutlined", "sort_order": 4, "is_active": True, "parent_id": None, "menu_type": "group", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01b5717017792f", "label": "首页", "path": "/home", "icon": "HomeOutlined", "sort_order": 0, "is_active": True, "parent_id": None, "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01b445f51dee00", "label": "我的项目", "path": "/projects", "icon": "AppstoreOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01b445f854a14a", "label": "AI创作", "path": "/conversation", "icon": "StarOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019e49af896982b070", "label": "爆款开头复刻", "path": "/initial", "icon": "CodeOutlined", "sort_order": 2, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019e4f26a8c4c0de5a", "label": "拆镜复刻", "path": "/removelens", "icon": "CameraOutlined", "sort_order": 3, "is_active": True, "parent_id": "0019eca2549ba477069", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01b7bb6e147445", "label": "爆款榜单", "path": "/popular", "icon": "FireOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019f01914a66279ec0", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f019267a4de06a0", "label": "创意广场", "path": "/creativeplaza", "icon": "BulbOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019f01914a66279ec0", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019e80aff6d0ea5843", "label": "素材云", "path": "/generated", "icon": "CloudOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca2735b9f3d944", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019f01b44606cc4c81", "label": "投放平台授权", "path": "/authorization", "icon": "UserOutlined", "sort_order": 0, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019ef924a3521924ab", "label": "素材ID列表", "path": "/materials", "icon": "FileTextOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019eb61d6fc2c14ebd", "label": "消耗列表", "path": "/consume", "icon": "FileTextOutlined", "sort_order": 1, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
{"id": "0019ed36864f343d347", "label": "素材前测", "path": "/pretest", "icon": "DatabaseOutlined", "sort_order": 2, "is_active": True, "parent_id": "0019eca27ec59922048", "menu_type": "page", "menu_target": "frontend", "is_default": True},
|
||||
]
|
||||
|
||||
for menu in frontend_menus:
|
||||
db.add(MenuConfig(**menu))
|
||||
|
||||
frontend_pages = [
|
||||
("/projects", "我的项目", "HomeOutlined", 0, "AI项目行业生成", True),
|
||||
("/conversation", "AI创作", "StarOutlined", 0, "AI对话生成", True),
|
||||
("/initial", "爆款开头复刻", "CodeOutlined", 0, "AI视频创作", False),
|
||||
("/removelens", "拆镜复刻", "CameraOutlined", 1, "AI视频创作", False),
|
||||
("/records", "项目记录", "PlayCircleOutlined", 0, "资产管理", True),
|
||||
("/generated", "素材云", "CloudOutlined", 1, "资产管理", True),
|
||||
("/authorization", "授权管理", "UserOutlined", 0, "媒体关联", False),
|
||||
("/consume", "消耗列表", "FileTextOutlined", 1, "媒体关联", False),
|
||||
]
|
||||
for path, label, icon, order, group_label, is_default in frontend_pages:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(MenuConfig.path == path).limit(1)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
db.add(
|
||||
MenuConfig(
|
||||
id=generate_id(),
|
||||
path=path,
|
||||
label=label,
|
||||
icon=icon,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
parent_id=frontend_group_ids.get(group_label),
|
||||
menu_type="page",
|
||||
menu_target="frontend",
|
||||
is_default=is_default,
|
||||
)
|
||||
)
|
||||
|
||||
admin_groups = [
|
||||
("模型设置", "RobotOutlined", 98),
|
||||
("模型配置", "RobotOutlined", 6),
|
||||
("系统设置", "SettingOutlined", 99),
|
||||
]
|
||||
admin_group_ids: dict[str, str] = {}
|
||||
for label, icon, order in admin_groups:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.label == label,
|
||||
MenuConfig.menu_type == "group",
|
||||
MenuConfig.menu_target == "admin",
|
||||
).limit(1)
|
||||
)
|
||||
group = existing.scalar_one_or_none()
|
||||
if group:
|
||||
admin_group_ids[label] = group.id
|
||||
else:
|
||||
admin_groups = [
|
||||
("模型设置", "RobotOutlined", 98),
|
||||
("模型配置", "RobotOutlined", 6),
|
||||
("系统设置", "SettingOutlined", 99),
|
||||
]
|
||||
admin_group_ids: dict[str, str] = {}
|
||||
for label, icon, order in admin_groups:
|
||||
gid = generate_id()
|
||||
admin_group_ids[label] = gid
|
||||
db.add(
|
||||
@@ -454,34 +405,28 @@ async def _seed_data():
|
||||
)
|
||||
)
|
||||
|
||||
admin_pages = [
|
||||
("/", "数据概览", "DashboardOutlined", 0, None),
|
||||
("/users", "用户管理", "UserOutlined", 1, None),
|
||||
("/credit-records", "交易流水", "WalletOutlined", 2, None),
|
||||
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
|
||||
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
|
||||
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
|
||||
("/notifications", "消息推送", "BellOutlined", 5, None),
|
||||
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
|
||||
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
|
||||
("/models", "模型配置", "RobotOutlined", 1, "模型设置"),
|
||||
("/image-engines", "图片模型", "PictureOutlined", 2, "模型设置"),
|
||||
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型设置"),
|
||||
("/payment", "支付配置", "DollarOutlined", 1, "系统设置"),
|
||||
("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"),
|
||||
("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"),
|
||||
("/settings", "系统设置", "SettingOutlined", 4, "系统设置"),
|
||||
("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"),
|
||||
("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"),
|
||||
]
|
||||
for path, label, icon, order, parent_group in admin_pages:
|
||||
existing = await db.execute(
|
||||
select(MenuConfig).where(
|
||||
MenuConfig.path == path,
|
||||
MenuConfig.menu_target == "admin",
|
||||
).limit(1)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
admin_pages = [
|
||||
("/", "数据概览", "DashboardOutlined", 0, None),
|
||||
("/users", "用户管理", "UserOutlined", 1, None),
|
||||
("/credit-records", "交易流水", "WalletOutlined", 2, None),
|
||||
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
|
||||
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
|
||||
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
|
||||
("/notifications", "消息推送", "BellOutlined", 5, None),
|
||||
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
|
||||
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
|
||||
("/models", "模型配置", "RobotOutlined", 1, "模型设置"),
|
||||
("/image-engines", "图片模型", "PictureOutlined", 2, "模型设置"),
|
||||
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型设置"),
|
||||
("/payment", "支付配置", "DollarOutlined", 1, "系统设置"),
|
||||
("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"),
|
||||
("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"),
|
||||
("/settings", "系统设置", "SettingOutlined", 4, "系统设置"),
|
||||
("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"),
|
||||
("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"),
|
||||
("/contact-requests", "联系请求", "MessageCircleOutlined", 29, "系统设置"),
|
||||
]
|
||||
for path, label, icon, order, parent_group in admin_pages:
|
||||
db.add(
|
||||
MenuConfig(
|
||||
id=generate_id(),
|
||||
@@ -495,6 +440,8 @@ async def _seed_data():
|
||||
parent_id=admin_group_ids.get(parent_group),
|
||||
)
|
||||
)
|
||||
|
||||
logging.info("Default menu configs inserted successfully")
|
||||
|
||||
# Seed recharge packages
|
||||
from app.models.recharge_package import RechargePackage
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class ContactRequest(Base, TimestampMixin):
|
||||
__tablename__ = "contact_requests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True)
|
||||
phone: Mapped[str] = mapped_column(String(20), index=True)
|
||||
company_name: Mapped[str] = mapped_column(String(128))
|
||||
industry: Mapped[str] = mapped_column(String(64))
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_handled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContactRequestCreate(BaseModel):
|
||||
phone: str = Field(..., description="手机号")
|
||||
company_name: str = Field(..., description="公司名称")
|
||||
industry: str = Field(..., description="行业")
|
||||
name: str = Field(..., description="姓名")
|
||||
message: str | None = Field(None, description="留言")
|
||||
|
||||
|
||||
class ContactRequestOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
phone: str
|
||||
company_name: str
|
||||
industry: str
|
||||
name: str
|
||||
message: str | None
|
||||
is_handled: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ContactRequestListOut(BaseModel):
|
||||
items: list[ContactRequestOut]
|
||||
total: int
|
||||
@@ -1,29 +1,17 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.enums.generation_status import (
|
||||
GenerationStatus,
|
||||
GenerationType,
|
||||
DURATIONS,
|
||||
ASPECT_RATIOS,
|
||||
RESOLUTIONS,
|
||||
IMAGE_SIZES,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
||||
from app.services.operation_log import log_operation
|
||||
|
||||
|
||||
class GenerationStatus(str, Enum):
|
||||
prompt_optimized = "prompt_optimized"
|
||||
generating = "generating"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
video = "video"
|
||||
image = "image"
|
||||
|
||||
|
||||
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||
IMAGE_SIZES = ["2K", "4K"]
|
||||
|
||||
|
||||
class OptimizeParams(BaseModel):
|
||||
project_id: str
|
||||
prompt: str = Field(..., max_length=500)
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
from pydantic import BaseModel
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.enums.notification import NotificationType
|
||||
from app.schemas.common import NaiveDatetime
|
||||
|
||||
|
||||
class NotificationCreditsOut(BaseModel):
|
||||
"""通知轮询接口携带的当前用户积分信息。"""
|
||||
|
||||
balance: float = Field(..., description="当前用户最新积分余额")
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
type: str
|
||||
type: NotificationType
|
||||
is_read: bool
|
||||
created_at: NaiveDatetime
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def normalize_type(cls, value):
|
||||
"""兼容历史数据或后台自定义通知类型,避免响应模型校验失败。"""
|
||||
if isinstance(value, NotificationType):
|
||||
return value
|
||||
if value is None:
|
||||
return NotificationType.UNKNOWN
|
||||
try:
|
||||
return NotificationType(str(value))
|
||||
except ValueError:
|
||||
return NotificationType.UNKNOWN
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationListOut(BaseModel):
|
||||
items: list[NotificationOut]
|
||||
total: int
|
||||
credits: NotificationCreditsOut
|
||||
|
||||
|
||||
class UnreadCountOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.enums.recent_generation import RecentGenerationModuleEnum, RecentGenerationResourceTypeEnum
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class RecentGenerationItemOut(BaseModel):
|
||||
"""最近生成记录响应项。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"generated_time": "2026-06-26T14:30:00",
|
||||
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
|
||||
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
|
||||
"module": "shot_replicate",
|
||||
"shot_task_set_id": "0019ef0000000000001",
|
||||
"shot_segment_id": "0019ef0000000000002",
|
||||
"module_project_id": "0019ef0000000000003",
|
||||
"module_step_id": "0019ef0000000000004",
|
||||
"generation_id": "0019ef0000000000005",
|
||||
"resource_type": "video",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
generated_time: NaiveDatetimeOptional = Field(
|
||||
None,
|
||||
description="生成完成时间。优先使用 generated_at;历史数据 generated_at 为空时回退 updated_at,再回退 created_at。",
|
||||
)
|
||||
result_url: str | None = Field(
|
||||
None,
|
||||
description="生成结果链接。resource_type=image 时为图片链接;resource_type=video 时为视频链接。返回前会按项目资源签名规则追加 exp/sign。",
|
||||
)
|
||||
cover_url: str | None = Field(
|
||||
None,
|
||||
description="视频封面链接。仅视频资源通常有值;图片资源或历史无封面数据时返回 null。返回前会按项目资源签名规则追加 exp/sign。",
|
||||
)
|
||||
module: RecentGenerationModuleEnum = Field(
|
||||
...,
|
||||
description=(
|
||||
"数据模块枚举:"
|
||||
"project=项目生成 GenerationRecord;"
|
||||
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async;"
|
||||
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate;"
|
||||
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
|
||||
),
|
||||
)
|
||||
shot_task_set_id: str | None = Field(
|
||||
None,
|
||||
description="关联拆镜总任务ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.task_set_id;其他模块返回 null。",
|
||||
)
|
||||
shot_segment_id: str | None = Field(
|
||||
None,
|
||||
description="关联拆镜片段ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.id;其他模块返回 null。",
|
||||
)
|
||||
module_project_id: str | None = Field(
|
||||
None,
|
||||
description="通用模块项目ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.project_id;project/chat_ai 返回 null。",
|
||||
)
|
||||
module_step_id: str | None = Field(
|
||||
None,
|
||||
description="通用模块步骤ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.id;project/chat_ai 返回 null。",
|
||||
)
|
||||
generation_id: str = Field(
|
||||
...,
|
||||
description="生成ID。project 模块为 generation_records.id;chat_ai/hot_opening_replicate/shot_replicate 模块为 chat_generation_tasks.id。",
|
||||
)
|
||||
resource_type: RecentGenerationResourceTypeEnum = Field(
|
||||
...,
|
||||
description="资源类型枚举:image=图片资源;video=视频资源。前端可据此决定预览组件。",
|
||||
)
|
||||
|
||||
|
||||
class RecentGenerationGroupOut(BaseModel):
|
||||
"""最近生成记录固定分组响应。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"project": [],
|
||||
"chat_ai": [
|
||||
{
|
||||
"generated_time": "2026-06-26T14:30:00",
|
||||
"result_url": "https://example.com/generate/image/demo.png?exp=1780000000&sign=xxxx",
|
||||
"cover_url": None,
|
||||
"module": "chat_ai",
|
||||
"shot_task_set_id": None,
|
||||
"shot_segment_id": None,
|
||||
"module_project_id": None,
|
||||
"module_step_id": None,
|
||||
"generation_id": "0019ef0000000000010",
|
||||
"resource_type": "image",
|
||||
}
|
||||
],
|
||||
"hot_opening_replicate": [],
|
||||
"shot_replicate": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
project: list[RecentGenerationItemOut] = Field(
|
||||
default_factory=list,
|
||||
description="项目生成最近记录数组,来源 generation_records。未查询该模块或无数据时返回空数组。",
|
||||
)
|
||||
chat_ai: list[RecentGenerationItemOut] = Field(
|
||||
default_factory=list,
|
||||
description="AI创作最近记录数组,来源 chat_generation_tasks,条件 generation_mode=chatapi_async。未查询该模块或无数据时返回空数组。",
|
||||
)
|
||||
hot_opening_replicate: list[RecentGenerationItemOut] = Field(
|
||||
default_factory=list,
|
||||
description="爆款开头复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=hot_opening_replicate。未查询该模块或无数据时返回空数组。",
|
||||
)
|
||||
shot_replicate: list[RecentGenerationItemOut] = Field(
|
||||
default_factory=list,
|
||||
description="拆镜复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=shot_replicate。未查询该模块或无数据时返回空数组。",
|
||||
)
|
||||
@@ -1,13 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SmsScene(str, Enum):
|
||||
register = "register"
|
||||
login = "login"
|
||||
common = "common"
|
||||
set_password = "set_password"
|
||||
from app.enums.sms import SmsScene
|
||||
|
||||
|
||||
class SmsSendRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserOAuthAccountOut(BaseModel):
|
||||
id: str = Field(..., description="主键")
|
||||
oauth_id: str = Field(..., description="授权表中的id")
|
||||
advertiser_id: Optional[str] = Field(None, description="广告主账户id")
|
||||
advertiser_name: Optional[str] = Field(None, description="广告账户名")
|
||||
advertiser_role: Optional[str] = Field(None, description="广告账户类型")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
|
||||
|
||||
class PaginationInfo(BaseModel):
|
||||
page: int = Field(..., description="当前页码")
|
||||
page_size: int = Field(..., description="每页数量")
|
||||
total: int = Field(..., description="总记录数")
|
||||
total_pages: int = Field(..., description="总页数")
|
||||
|
||||
|
||||
class OAuthAccountListResponse(BaseModel):
|
||||
code: int = Field(0, description="返回码,0表示成功")
|
||||
message: str = Field("查询成功", description="返回消息")
|
||||
data: List[UserOAuthAccountOut] = Field(..., description="授权账户列表数据")
|
||||
pagination: PaginationInfo = Field(..., description="分页信息")
|
||||
|
||||
|
||||
class DeleteOAuthAccountRequest(BaseModel):
|
||||
id: str = Field(..., description="授权账户表id")
|
||||
@@ -2,36 +2,40 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
|
||||
|
||||
async def notify_chat_generation_task_finished(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
"""通知业务模块 ChatGenerationTask 已进入终态。
|
||||
|
||||
当前用于爆款开头复刻:
|
||||
- image_generate 完成后自动进入 video_prompt_optimize
|
||||
- video_generate 完成后总任务完成
|
||||
该方法必须幂等:下载恢复任务、重试任务、服务重启补偿都可能重复调用。
|
||||
具体模块服务需要自行判断 step/project 是否已经完成或失败,避免重复推进。
|
||||
"""
|
||||
if not task:
|
||||
return
|
||||
if task.generation_mode == "hot_opening_replicate":
|
||||
|
||||
status = getattr(task, "status", None)
|
||||
generation_mode = getattr(task, "generation_mode", None)
|
||||
|
||||
if generation_mode == GenerationMode.HOT_OPENING_REPLICATE.value:
|
||||
from app.services.hot_opening_replicate_service import (
|
||||
handle_chat_generation_task_completed,
|
||||
handle_chat_generation_task_failed,
|
||||
)
|
||||
if task.status == "completed":
|
||||
if status == ChatGenerationTaskStatus.COMPLETED.value:
|
||||
await handle_chat_generation_task_completed(db, task)
|
||||
elif task.status == "failed":
|
||||
elif status == ChatGenerationTaskStatus.FAILED.value:
|
||||
await handle_chat_generation_task_failed(db, task)
|
||||
return
|
||||
|
||||
if task.generation_mode == "shot_replicate":
|
||||
if generation_mode == GenerationMode.SHOT_REPLICATE.value:
|
||||
from app.services.shot_replicate_flow_service import (
|
||||
handle_chat_generation_task_completed,
|
||||
handle_chat_generation_task_failed,
|
||||
)
|
||||
if task.status == "completed":
|
||||
if status == ChatGenerationTaskStatus.COMPLETED.value:
|
||||
await handle_chat_generation_task_completed(db, task)
|
||||
elif task.status == "failed":
|
||||
elif status == ChatGenerationTaskStatus.FAILED.value:
|
||||
await handle_chat_generation_task_failed(db, task)
|
||||
return
|
||||
|
||||
@@ -9,6 +9,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.generation_task import (
|
||||
ALLOWED_GENERATION_MODES,
|
||||
ChatGenerationPipelineStage,
|
||||
ChatGenerationTaskEventType,
|
||||
ChatGenerationTaskStatus,
|
||||
)
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.services.celery_download_recovery_service import (
|
||||
ensure_aware_utc,
|
||||
@@ -17,9 +23,8 @@ from app.services.celery_download_recovery_service import (
|
||||
postpone_download_active_check,
|
||||
remove_download_active,
|
||||
)
|
||||
from app.services.generation_log_service import log_provider_call, log_task_event
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
from app.services.generation_provider_service import poll_provider_task
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.services.redis_registry_service import (
|
||||
redis_get_due_registry_ids,
|
||||
@@ -30,7 +35,6 @@ from app.services.redis_registry_service import (
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"}
|
||||
POLL_QUEUE = "gen_provider_poll"
|
||||
|
||||
|
||||
@@ -59,11 +63,11 @@ def _is_queue_timeout(task: ChatGenerationTask, now: datetime | None = None) ->
|
||||
|
||||
|
||||
def _is_final_task_state(task: ChatGenerationTask) -> bool:
|
||||
return task.status in ("completed", "failed") or task.pipeline_stage in (
|
||||
"done",
|
||||
"failed",
|
||||
"timeout",
|
||||
"download_failed",
|
||||
return task.status in (ChatGenerationTaskStatus.COMPLETED.value, ChatGenerationTaskStatus.FAILED.value) or task.pipeline_stage in (
|
||||
ChatGenerationPipelineStage.DONE.value,
|
||||
ChatGenerationPipelineStage.FAILED.value,
|
||||
ChatGenerationPipelineStage.TIMEOUT.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,19 +141,31 @@ async def recover_one_download_task(
|
||||
if _is_final_task_state(task):
|
||||
await remove_download_active(task.id)
|
||||
return "clean_final_state"
|
||||
if task.status != "generating":
|
||||
if task.status != ChatGenerationTaskStatus.GENERATING.value:
|
||||
await remove_download_active(task.id)
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NOT_GENERATING.value,
|
||||
message=f"{source} 下载恢复跳过:任务不是 generating",
|
||||
detail={"status": task.status, "stage": task.pipeline_stage},
|
||||
)
|
||||
return "clean_not_generating"
|
||||
if not task.remote_result_url:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NO_REMOTE_RESULT_URL.value,
|
||||
message=f"{source} 下载恢复跳过:缺少 remote_result_url",
|
||||
detail={"status": task.status, "stage": task.pipeline_stage},
|
||||
)
|
||||
return "skip_no_remote_result_url"
|
||||
|
||||
stage = task.pipeline_stage
|
||||
redis_payload = payload or {}
|
||||
|
||||
if stage == "result_ready":
|
||||
if stage == ChatGenerationPipelineStage.RESULT_READY.value:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_RECOVERY_ENQUEUE",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE.value,
|
||||
message=f"{source} 发现 result_ready 未完成下载,启动时恢复投递下载任务",
|
||||
detail={"payload": redis_payload},
|
||||
)
|
||||
@@ -165,7 +181,7 @@ async def recover_one_download_task(
|
||||
if _is_queue_timeout(task, current_time):
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_RECOVERY_ENQUEUE",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE.value,
|
||||
message=f"{source} 发现 download_queued 长时间未消费,启动时恢复投递下载任务",
|
||||
detail={"payload": redis_payload},
|
||||
)
|
||||
@@ -188,7 +204,7 @@ async def recover_one_download_task(
|
||||
if _is_expired(task.download_lease_until, current_time):
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_RECOVERY_ENQUEUE",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE.value,
|
||||
message=f"{source} 发现 downloading lease 过期,启动时恢复投递下载任务",
|
||||
detail={"payload": redis_payload},
|
||||
)
|
||||
@@ -211,7 +227,7 @@ async def recover_one_download_task(
|
||||
if _is_expired(task.download_next_retry_at, current_time):
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_RECOVERY_ENQUEUE",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE.value,
|
||||
message=f"{source} 发现 retry_waiting 到期,启动时恢复投递下载任务",
|
||||
detail={"payload": redis_payload},
|
||||
)
|
||||
@@ -276,11 +292,16 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]),
|
||||
ChatGenerationTask.status == "generating",
|
||||
ChatGenerationTask.generation_mode.in_(list(ALLOWED_GENERATION_MODES)),
|
||||
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
|
||||
ChatGenerationTask.remote_result_url.is_not(None),
|
||||
ChatGenerationTask.pipeline_stage.in_(
|
||||
["result_ready", "download_queued", "downloading", "retry_waiting"]
|
||||
[
|
||||
ChatGenerationPipelineStage.RESULT_READY.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
|
||||
ChatGenerationPipelineStage.DOWNLOADING.value,
|
||||
ChatGenerationPipelineStage.RETRY_WAITING.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(ChatGenerationTask.updated_at.asc())
|
||||
@@ -314,7 +335,7 @@ async def _mark_timeout(
|
||||
db,
|
||||
task=task,
|
||||
error_message=error_message,
|
||||
pipeline_stage="timeout",
|
||||
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
||||
)
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
@@ -323,7 +344,7 @@ async def _mark_timeout(
|
||||
task,
|
||||
event_type="TASK_TIMEOUT",
|
||||
to_status="failed",
|
||||
to_stage="timeout",
|
||||
to_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
||||
)
|
||||
return "mark_timeout"
|
||||
|
||||
@@ -340,7 +361,7 @@ async def _mark_failed(
|
||||
db,
|
||||
task=task,
|
||||
error_message=error_message,
|
||||
pipeline_stage="failed",
|
||||
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
|
||||
)
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
@@ -349,93 +370,6 @@ async def _mark_failed(
|
||||
return "mark_failed"
|
||||
|
||||
|
||||
async def _try_final_poll_before_timeout(db: AsyncSession, task: ChatGenerationTask) -> str:
|
||||
"""超时前最后查一次供应商,避免 Celery 中断导致本地假超时。
|
||||
|
||||
如果供应商已经成功,继续进入下载;如果仍 running 或查询失败,再按超时处理。
|
||||
"""
|
||||
from app.tasks.generation_download_tasks import enqueue_download_task
|
||||
|
||||
if not (task.provider_task_id or task.seedance_task_id):
|
||||
return await _mark_timeout(db, task)
|
||||
|
||||
try:
|
||||
poll_result = await poll_provider_task(db, task)
|
||||
status = poll_result.get("status")
|
||||
response_data = poll_result.get("response_data")
|
||||
except Exception as exc:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="FINAL_POLL_BEFORE_TIMEOUT_ERROR",
|
||||
message=str(exc),
|
||||
)
|
||||
return await _mark_timeout(db, task)
|
||||
|
||||
try:
|
||||
provider_response = json.loads(response_data or "{}")
|
||||
except Exception:
|
||||
provider_response = {"raw": response_data}
|
||||
|
||||
snapshot = _engine_snapshot(task)
|
||||
await log_provider_call(
|
||||
task,
|
||||
provider=snapshot.get("provider") or "ark",
|
||||
api_type=f"{task.gen_type}_final_poll_before_timeout",
|
||||
model=snapshot.get("model_name"),
|
||||
engine_id=task.engine_id,
|
||||
status="success",
|
||||
provider_task_id=task.seedance_task_id or task.provider_task_id,
|
||||
response_data=provider_response,
|
||||
)
|
||||
|
||||
if _is_success(status):
|
||||
if task.gen_type == "image":
|
||||
task.remote_result_url = poll_result.get("image_url")
|
||||
task.image_tokens_used = poll_result.get("image_tokens", 0) or 0
|
||||
else:
|
||||
task.remote_result_url = poll_result.get("video_url")
|
||||
task.video_tokens_used = poll_result.get("video_tokens", 0) or 0
|
||||
|
||||
task.provider_response_json = response_data
|
||||
if not task.remote_result_url:
|
||||
return await _mark_failed(
|
||||
db,
|
||||
task,
|
||||
error_message="供应商任务成功但未返回结果URL",
|
||||
detail=poll_result,
|
||||
)
|
||||
|
||||
task.pipeline_stage = "result_ready"
|
||||
task.retry_count = 0
|
||||
await db.commit()
|
||||
await _remove_poll_active(task.id)
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="POLL_SUCCESS_AFTER_TIMEOUT_RECOVERY",
|
||||
to_stage="result_ready",
|
||||
detail=poll_result,
|
||||
)
|
||||
await enqueue_download_task(db, task, recover=True, reason="final_poll_before_timeout_success")
|
||||
return "recover_timeout_success_to_download"
|
||||
|
||||
if _is_failed(status):
|
||||
task.provider_response_json = response_data
|
||||
return await _mark_failed(
|
||||
db,
|
||||
task,
|
||||
error_message=poll_result.get("error") or f"供应商任务失败: {status}",
|
||||
detail=poll_result,
|
||||
)
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="FINAL_POLL_BEFORE_TIMEOUT_PENDING",
|
||||
message=f"status={status}",
|
||||
detail=poll_result,
|
||||
)
|
||||
return await _mark_timeout(db, task)
|
||||
|
||||
|
||||
async def recover_one_generation_task(
|
||||
db: AsyncSession,
|
||||
task: ChatGenerationTask,
|
||||
@@ -443,6 +377,14 @@ async def recover_one_generation_task(
|
||||
payload: dict[str, Any] | None = None,
|
||||
source: str = "startup_db",
|
||||
) -> str:
|
||||
"""恢复单个生成任务。
|
||||
|
||||
分流原则:
|
||||
1. 已有 remote_result_url:只恢复下载,不 poll,不重新 create。
|
||||
2. 已有 provider_task_id/seedance_task_id:恢复 poll。
|
||||
3. 无结果 URL、无供应商任务 ID:deadline 未过才恢复 create。
|
||||
4. 无结果 URL、无供应商任务 ID:deadline 已过直接超时失败,不再补救生成。
|
||||
"""
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
from app.tasks.generation_download_tasks import enqueue_download_task
|
||||
from app.tasks.generation_poll_tasks import poll_generation_task, register_poll_active
|
||||
@@ -458,37 +400,107 @@ async def recover_one_generation_task(
|
||||
if _is_final_task_state(task):
|
||||
await _remove_poll_active(task.id)
|
||||
return "clean_final_state"
|
||||
if task.status != "generating":
|
||||
if task.status != ChatGenerationTaskStatus.GENERATING.value:
|
||||
await _remove_poll_active(task.id)
|
||||
return "clean_not_generating"
|
||||
|
||||
if task.deadline_at and _is_expired(task.deadline_at, current_time):
|
||||
if task.pipeline_stage in ("waiting_remote", "polling"):
|
||||
return await _try_final_poll_before_timeout(db, task)
|
||||
return await _mark_timeout(db, task)
|
||||
has_remote_result = bool(str(task.remote_result_url or "").strip())
|
||||
has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip())
|
||||
is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time))
|
||||
|
||||
if task.pipeline_stage in ("queued", "preparing", "creating_provider_task"):
|
||||
if task.provider_task_id or task.seedance_task_id:
|
||||
task.pipeline_stage = "waiting_remote"
|
||||
# 最高优先级:只要远程结果 URL 已经落库,说明生成侧已经成功。
|
||||
# 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。
|
||||
if has_remote_result:
|
||||
await _remove_poll_active(task.id)
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现任务已存在 remote_result_url,恢复投递下载队列",
|
||||
detail={
|
||||
"pipeline_stage": task.pipeline_stage,
|
||||
"payload": redis_payload,
|
||||
"deadline_expired": is_deadline_expired,
|
||||
},
|
||||
)
|
||||
await enqueue_download_task(
|
||||
db,
|
||||
task,
|
||||
recover=True,
|
||||
reason=f"{source}_has_remote_result_url",
|
||||
)
|
||||
return "recover_download_has_remote_result"
|
||||
|
||||
# 已经过 deadline 且没有结果 URL:
|
||||
# - 有供应商任务 ID:交给 poll worker 做最后一次状态确认;
|
||||
# - 没有供应商任务 ID:说明没有可查询的远程任务,直接按超时失败处理,不再重新 create。
|
||||
if is_deadline_expired:
|
||||
if has_provider_task_id:
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value
|
||||
await db.commit()
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现创建阶段已存在供应商任务ID,恢复投递轮询队列",
|
||||
message=f"{source} 发现任务已到 deadline 且存在供应商任务ID,投递 poll 队列做最终查询",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
poll_generation_task.apply_async(args=[task.id], queue=POLL_QUEUE, countdown=0)
|
||||
await register_poll_active(
|
||||
task,
|
||||
check_at=_poll_queue_timeout_at(),
|
||||
reason=f"{source}_create_stage_has_provider_id",
|
||||
reason=f"{source}_deadline_final_poll",
|
||||
)
|
||||
return "recover_poll_from_create_stage"
|
||||
return "recover_deadline_final_poll"
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_TIMEOUT",
|
||||
message=f"{source} 发现任务已到 deadline,且没有 remote_result_url/供应商任务ID,按超时失败处理",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
return await _mark_timeout(db, task)
|
||||
|
||||
# 未过 deadline:有供应商任务 ID 才允许恢复到 poll 队列。
|
||||
if has_provider_task_id:
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value
|
||||
await db.commit()
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现创建阶段任务未完成,恢复投递创建队列",
|
||||
message=f"{source} 发现任务存在供应商任务ID,恢复投递轮询队列",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
poll_generation_task.apply_async(args=[task.id], queue=POLL_QUEUE, countdown=0)
|
||||
await register_poll_active(
|
||||
task,
|
||||
check_at=_poll_queue_timeout_at(),
|
||||
reason=f"{source}_has_provider_task_id",
|
||||
)
|
||||
return "recover_poll_has_provider_id"
|
||||
|
||||
# 未过 deadline,且没有结果 URL / 供应商任务 ID:
|
||||
# 图片同步任务会重新进入 submit_image_task;视频/其它任务会重新创建供应商任务。
|
||||
# 这里不能投 poll,因为没有 provider_task_id/seedance_task_id 可查询。
|
||||
recoverable_create_stages = {
|
||||
ChatGenerationPipelineStage.QUEUED.value,
|
||||
ChatGenerationPipelineStage.PREPARING.value,
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
||||
ChatGenerationPipelineStage.POLLING.value,
|
||||
}
|
||||
if task.pipeline_stage in recoverable_create_stages:
|
||||
if task.pipeline_stage not in (
|
||||
ChatGenerationPipelineStage.QUEUED.value,
|
||||
ChatGenerationPipelineStage.PREPARING.value,
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
):
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||
await db.commit()
|
||||
|
||||
await _remove_poll_active(task.id)
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现任务未超时且缺少 remote_result_url/供应商任务ID,恢复投递创建队列",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
@@ -496,67 +508,25 @@ async def recover_one_generation_task(
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
return "recover_create"
|
||||
return "recover_create_no_remote_no_provider_before_deadline"
|
||||
|
||||
if task.pipeline_stage in ("waiting_remote", "polling"):
|
||||
if task.remote_result_url:
|
||||
await _remove_poll_active(task.id)
|
||||
await enqueue_download_task(
|
||||
db,
|
||||
task,
|
||||
recover=True,
|
||||
reason=f"{source}_waiting_remote_has_result",
|
||||
)
|
||||
return "recover_waiting_has_result"
|
||||
|
||||
if task.provider_task_id or task.seedance_task_id:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现远程等待/轮询阶段任务未完成,恢复投递轮询队列",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
task.pipeline_stage = "waiting_remote"
|
||||
await db.commit()
|
||||
poll_generation_task.apply_async(
|
||||
args=[task.id],
|
||||
queue=POLL_QUEUE,
|
||||
countdown=0,
|
||||
)
|
||||
await register_poll_active(
|
||||
task,
|
||||
check_at=_poll_queue_timeout_at(),
|
||||
reason=f"{source}_recover_poll",
|
||||
)
|
||||
return "recover_poll"
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现任务缺少供应商任务ID,恢复投递创建队列",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
task.pipeline_stage = "queued"
|
||||
# result_ready 但没有 URL 是脏状态;未过 deadline 时回创建队列重新处理,过期上面已标记超时。
|
||||
if task.pipeline_stage == ChatGenerationPipelineStage.RESULT_READY.value:
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||
await db.commit()
|
||||
await _remove_poll_active(task.id)
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="GENERATION_RECOVERY_ENQUEUE",
|
||||
message=f"{source} 发现 result_ready 但缺少 remote_result_url,未超时,恢复投递创建队列",
|
||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
||||
)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
args=[task.id],
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
return "recover_create_missing_provider_id"
|
||||
|
||||
if task.pipeline_stage == "result_ready":
|
||||
await _remove_poll_active(task.id)
|
||||
if task.remote_result_url:
|
||||
await enqueue_download_task(
|
||||
db,
|
||||
task,
|
||||
recover=True,
|
||||
reason=f"{source}_generation_result_ready",
|
||||
)
|
||||
return "recover_result_ready"
|
||||
return "skip_result_ready_no_url"
|
||||
return "recover_create_result_ready_no_url_before_deadline"
|
||||
|
||||
return f"skip_stage_{task.pipeline_stage}"
|
||||
|
||||
@@ -617,8 +587,8 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]),
|
||||
ChatGenerationTask.status == "generating",
|
||||
ChatGenerationTask.generation_mode.in_(list(ALLOWED_GENERATION_MODES)),
|
||||
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
|
||||
ChatGenerationTask.pipeline_stage.in_(
|
||||
[
|
||||
"queued",
|
||||
@@ -656,11 +626,8 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
if len(tasks) < batch_size or progressed_this_round <= 0:
|
||||
break
|
||||
|
||||
# 下载阶段单独跑 DB fallback。
|
||||
download_result = await recover_download_tasks_once(db)
|
||||
return {
|
||||
"checked": len(checked_ids),
|
||||
"db_checked": total_db_checked,
|
||||
"results": results,
|
||||
"download_recovery": download_result,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordAction,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordOwnerType,
|
||||
CreditRecordSourceModule,
|
||||
)
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_json_dict(value: Any) -> dict[str, Any]:
|
||||
if not value:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_usage(provider_response: Any) -> dict[str, Any]:
|
||||
data = _safe_json_dict(provider_response)
|
||||
usage = data.get("usage")
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
|
||||
def _normalize_media_tokens(
|
||||
*,
|
||||
gen_type: str | None,
|
||||
provider_response: Any = None,
|
||||
fallback_total: int | None = None,
|
||||
) -> tuple[int, int, int]:
|
||||
usage = _extract_usage(provider_response)
|
||||
input_tokens = _safe_int(usage.get("input_tokens"), 0)
|
||||
output_tokens = _safe_int(
|
||||
usage.get("output_tokens"),
|
||||
_safe_int(usage.get("generated_tokens"), 0),
|
||||
)
|
||||
total_tokens = _safe_int(usage.get("total_tokens"), 0)
|
||||
|
||||
if total_tokens <= 0:
|
||||
total_tokens = _safe_int(fallback_total, 0)
|
||||
if output_tokens <= 0:
|
||||
output_tokens = max(0, total_tokens - input_tokens)
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# 图片生成多数供应商只返回 output/total,没有 input;保持 input=0。视频同理兼容缺字段。
|
||||
return input_tokens, output_tokens, total_tokens
|
||||
|
||||
|
||||
def _engine_model_from_provider_response(provider_response: Any) -> str | None:
|
||||
data = _safe_json_dict(provider_response)
|
||||
model = data.get("model")
|
||||
return str(model) if model else None
|
||||
|
||||
|
||||
async def _find_latest_media_charge(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
owner_type: str,
|
||||
owner_id: str,
|
||||
media_type: str | None,
|
||||
) -> CreditRecord | None:
|
||||
query = (
|
||||
select(CreditRecord)
|
||||
.where(CreditRecord.user_id == user_id)
|
||||
.where(CreditRecord.type == "consume")
|
||||
.where(CreditRecord.owner_type == owner_type)
|
||||
.where(CreditRecord.owner_id == owner_id)
|
||||
.where(CreditRecord.charge_kind == CreditRecordChargeKind.MEDIA.value)
|
||||
.where(CreditRecord.charge_action == CreditRecordAction.CHARGE.value)
|
||||
)
|
||||
if media_type:
|
||||
query = query.where(CreditRecord.media_type == media_type)
|
||||
query = query.order_by(CreditRecord.attempt_no.desc().nullslast(), CreditRecord.created_at.desc()).limit(1)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_or_create_token_usage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
charge: CreditRecord,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
total_tokens: int,
|
||||
model_config_id: str | None = None,
|
||||
) -> TokenUsage:
|
||||
token_usage: TokenUsage | None = None
|
||||
if charge.token_usage_id:
|
||||
result = await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1))
|
||||
token_usage = result.scalar_one_or_none()
|
||||
if token_usage is None and charge.biz_key:
|
||||
result = await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1))
|
||||
token_usage = result.scalar_one_or_none()
|
||||
if token_usage is None:
|
||||
token_usage = TokenUsage(
|
||||
id=generate_id(),
|
||||
user_id=charge.user_id,
|
||||
model_config_id=model_config_id,
|
||||
owner_type=charge.owner_type,
|
||||
owner_id=charge.owner_id,
|
||||
biz_key=charge.biz_key,
|
||||
source_module=charge.source_module,
|
||||
source_step_code=charge.source_step_code,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
db.add(token_usage)
|
||||
await db.flush()
|
||||
else:
|
||||
token_usage.user_id = token_usage.user_id or charge.user_id
|
||||
token_usage.model_config_id = token_usage.model_config_id or model_config_id
|
||||
token_usage.owner_type = token_usage.owner_type or charge.owner_type
|
||||
token_usage.owner_id = token_usage.owner_id or charge.owner_id
|
||||
token_usage.biz_key = token_usage.biz_key or charge.biz_key
|
||||
token_usage.source_module = token_usage.source_module or charge.source_module
|
||||
token_usage.source_step_code = token_usage.source_step_code or charge.source_step_code
|
||||
token_usage.input_tokens = input_tokens
|
||||
token_usage.output_tokens = output_tokens
|
||||
token_usage.total_tokens = total_tokens
|
||||
return token_usage
|
||||
|
||||
|
||||
async def _sync_charge_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
charge: CreditRecord | None,
|
||||
gen_type: str | None,
|
||||
provider_response: Any = None,
|
||||
fallback_total: int | None = None,
|
||||
) -> CreditRecord | None:
|
||||
if not charge:
|
||||
return None
|
||||
|
||||
input_tokens, output_tokens, total_tokens = _normalize_media_tokens(
|
||||
gen_type=gen_type,
|
||||
provider_response=provider_response,
|
||||
fallback_total=fallback_total,
|
||||
)
|
||||
if total_tokens <= 0:
|
||||
return charge
|
||||
|
||||
token_usage = await _get_or_create_token_usage(
|
||||
db,
|
||||
charge=charge,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
model_config_id=None,
|
||||
)
|
||||
|
||||
charge.token_usage_id = token_usage.id
|
||||
charge.input_tokens = input_tokens
|
||||
charge.output_tokens = output_tokens
|
||||
charge.total_tokens = total_tokens
|
||||
|
||||
# 兼容旧流水扣费时未冷备 engine_model_name 的场景,能从 provider response 推出来就补充。
|
||||
provider_model = _engine_model_from_provider_response(provider_response)
|
||||
if provider_model and not charge.engine_model_name:
|
||||
charge.engine_model_name = provider_model
|
||||
return charge
|
||||
|
||||
|
||||
async def sync_chat_generation_task_media_token_snapshot(
|
||||
db: AsyncSession,
|
||||
task: ChatGenerationTask,
|
||||
*,
|
||||
provider_response: Any = None,
|
||||
) -> CreditRecord | None:
|
||||
"""把 ChatGenerationTask 图片/视频媒体生成 token 后置快照回填到积分流水。
|
||||
|
||||
媒体扣费发生在创建任务前,供应商 usage 只能在创建/轮询成功后拿到,
|
||||
所以这里按 owner_type + owner_id + media_type 找到对应 media charge 流水并回填。
|
||||
"""
|
||||
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)):
|
||||
return None
|
||||
if not task:
|
||||
return None
|
||||
|
||||
gen_type = (getattr(task, "gen_type", None) or "").lower().strip()
|
||||
fallback_total = task.image_tokens_used if gen_type == "image" else task.video_tokens_used
|
||||
response = provider_response if provider_response is not None else getattr(task, "provider_response_json", None)
|
||||
charge = await _find_latest_media_charge(
|
||||
db,
|
||||
user_id=task.user_id,
|
||||
owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value,
|
||||
owner_id=task.id,
|
||||
media_type=gen_type or None,
|
||||
)
|
||||
return await _sync_charge_snapshot(
|
||||
db,
|
||||
charge=charge,
|
||||
gen_type=gen_type,
|
||||
provider_response=response,
|
||||
fallback_total=fallback_total,
|
||||
)
|
||||
|
||||
|
||||
async def sync_generation_record_media_token_snapshot(
|
||||
db: AsyncSession,
|
||||
record: GenerationRecord,
|
||||
*,
|
||||
provider_response: Any = None,
|
||||
) -> CreditRecord | None:
|
||||
"""把旧 GenerationRecord 图片/视频媒体生成 token 后置快照回填到积分流水。"""
|
||||
if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)):
|
||||
return None
|
||||
if not record:
|
||||
return None
|
||||
|
||||
gen_type = (getattr(record, "gen_type", None) or "").lower().strip()
|
||||
fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used
|
||||
charge = await _find_latest_media_charge(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
owner_type=CreditRecordOwnerType.GENERATION_RECORD.value,
|
||||
owner_id=record.id,
|
||||
media_type=gen_type or None,
|
||||
)
|
||||
return await _sync_charge_snapshot(
|
||||
db,
|
||||
charge=charge,
|
||||
gen_type=gen_type,
|
||||
provider_response=provider_response,
|
||||
fallback_total=fallback_total,
|
||||
)
|
||||
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.generation_task import GenerationType
|
||||
from app.enums.recent_generation import (
|
||||
RECENT_GENERATION_ALL_MODULES,
|
||||
RECENT_GENERATION_CHAT_TASK_MODULES,
|
||||
RECENT_GENERATION_COMPLETED_STATUS,
|
||||
RECENT_GENERATION_MODULE_TO_TASK_MODE,
|
||||
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE,
|
||||
RecentGenerationModuleEnum,
|
||||
RecentGenerationResourceTypeEnum,
|
||||
)
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.schemas.recent_generation import RecentGenerationGroupOut, RecentGenerationItemOut
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
|
||||
DEFAULT_RECENT_GENERATION_LIMIT = 5
|
||||
MAX_RECENT_GENERATION_LIMIT = 100
|
||||
|
||||
|
||||
class _StepLinkInfo(TypedDict):
|
||||
module_project_id: str | None
|
||||
module_step_id: str | None
|
||||
module: str | None
|
||||
|
||||
|
||||
class _ShotLinkInfo(TypedDict):
|
||||
shot_task_set_id: str | None
|
||||
shot_segment_id: str | None
|
||||
|
||||
|
||||
def _normalize_limit(limit: int | None) -> int:
|
||||
if limit is None:
|
||||
return DEFAULT_RECENT_GENERATION_LIMIT
|
||||
return min(max(int(limit), 1), MAX_RECENT_GENERATION_LIMIT)
|
||||
|
||||
|
||||
def _normalize_modules(
|
||||
modules: Iterable[RecentGenerationModuleEnum] | None,
|
||||
) -> list[RecentGenerationModuleEnum]:
|
||||
if not modules:
|
||||
return list(RECENT_GENERATION_ALL_MODULES)
|
||||
|
||||
normalized: list[RecentGenerationModuleEnum] = []
|
||||
seen: set[RecentGenerationModuleEnum] = set()
|
||||
for module in modules:
|
||||
module_enum = RecentGenerationModuleEnum(module)
|
||||
if module_enum not in seen:
|
||||
normalized.append(module_enum)
|
||||
seen.add(module_enum)
|
||||
return normalized
|
||||
|
||||
|
||||
def _has_url(column) -> Any:
|
||||
return and_(column.is_not(None), column != "")
|
||||
|
||||
|
||||
def _detect_resource_type(
|
||||
gen_type: str | None,
|
||||
image_url: str | None,
|
||||
video_url: str | None,
|
||||
) -> RecentGenerationResourceTypeEnum:
|
||||
gen_type_value = (gen_type or "").strip().lower()
|
||||
|
||||
if gen_type_value == GenerationType.IMAGE.value and image_url:
|
||||
return RecentGenerationResourceTypeEnum.IMAGE
|
||||
if gen_type_value == GenerationType.VIDEO.value and video_url:
|
||||
return RecentGenerationResourceTypeEnum.VIDEO
|
||||
if video_url:
|
||||
return RecentGenerationResourceTypeEnum.VIDEO
|
||||
return RecentGenerationResourceTypeEnum.IMAGE
|
||||
|
||||
|
||||
def _sign_url(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
return build_resource_signed_url(url)
|
||||
|
||||
|
||||
def _build_item(
|
||||
*,
|
||||
generation_id: str,
|
||||
module: RecentGenerationModuleEnum,
|
||||
gen_type: str | None,
|
||||
image_url: str | None,
|
||||
video_url: str | None,
|
||||
video_cover_url: str | None,
|
||||
generated_time: datetime | None,
|
||||
step_info: _StepLinkInfo | None = None,
|
||||
shot_info: _ShotLinkInfo | None = None,
|
||||
) -> RecentGenerationItemOut:
|
||||
resource_type = _detect_resource_type(
|
||||
gen_type=gen_type,
|
||||
image_url=image_url,
|
||||
video_url=video_url,
|
||||
)
|
||||
|
||||
raw_result_url = video_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else image_url
|
||||
raw_cover_url = video_cover_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else None
|
||||
|
||||
return RecentGenerationItemOut(
|
||||
generated_time=generated_time,
|
||||
result_url=_sign_url(raw_result_url),
|
||||
cover_url=_sign_url(raw_cover_url),
|
||||
module=module,
|
||||
shot_task_set_id=shot_info["shot_task_set_id"] if shot_info else None,
|
||||
shot_segment_id=shot_info["shot_segment_id"] if shot_info else None,
|
||||
module_project_id=step_info["module_project_id"] if step_info else None,
|
||||
module_step_id=step_info["module_step_id"] if step_info else None,
|
||||
generation_id=generation_id,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
|
||||
|
||||
async def _list_project_recent_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
limit: int,
|
||||
) -> list[RecentGenerationItemOut]:
|
||||
generated_time_expr = func.coalesce(
|
||||
GenerationRecord.generated_at,
|
||||
GenerationRecord.updated_at,
|
||||
GenerationRecord.created_at,
|
||||
).label("generated_time")
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
GenerationRecord.id.label("generation_id"),
|
||||
GenerationRecord.gen_type.label("gen_type"),
|
||||
GenerationRecord.image_url.label("image_url"),
|
||||
GenerationRecord.video_url.label("video_url"),
|
||||
GenerationRecord.video_cover_url.label("video_cover_url"),
|
||||
generated_time_expr,
|
||||
)
|
||||
.where(
|
||||
GenerationRecord.user_id == user_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
GenerationRecord.status == RECENT_GENERATION_COMPLETED_STATUS,
|
||||
or_(_has_url(GenerationRecord.image_url), _has_url(GenerationRecord.video_url)),
|
||||
)
|
||||
.order_by(generated_time_expr.desc(), GenerationRecord.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = (await db.execute(stmt)).mappings().all()
|
||||
return [
|
||||
_build_item(
|
||||
generation_id=row["generation_id"],
|
||||
module=RecentGenerationModuleEnum.PROJECT,
|
||||
gen_type=row["gen_type"],
|
||||
image_url=row["image_url"],
|
||||
video_url=row["video_url"],
|
||||
video_cover_url=row["video_cover_url"],
|
||||
generated_time=row["generated_time"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def _list_chat_task_recent_rows(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
modules: list[RecentGenerationModuleEnum],
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
task_mode_values = [
|
||||
RECENT_GENERATION_MODULE_TO_TASK_MODE[module].value
|
||||
for module in modules
|
||||
if module in RECENT_GENERATION_CHAT_TASK_MODULES
|
||||
]
|
||||
if not task_mode_values:
|
||||
return []
|
||||
|
||||
generated_time_expr = func.coalesce(
|
||||
ChatGenerationTask.generated_at,
|
||||
ChatGenerationTask.updated_at,
|
||||
ChatGenerationTask.created_at,
|
||||
)
|
||||
|
||||
ranked_subquery = (
|
||||
select(
|
||||
ChatGenerationTask.id.label("generation_id"),
|
||||
ChatGenerationTask.generation_mode.label("generation_mode"),
|
||||
ChatGenerationTask.gen_type.label("gen_type"),
|
||||
ChatGenerationTask.image_url.label("image_url"),
|
||||
ChatGenerationTask.video_url.label("video_url"),
|
||||
ChatGenerationTask.video_cover_url.label("video_cover_url"),
|
||||
generated_time_expr.label("generated_time"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=ChatGenerationTask.generation_mode,
|
||||
order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()),
|
||||
)
|
||||
.label("row_num"),
|
||||
)
|
||||
.where(
|
||||
ChatGenerationTask.user_id == user_id,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.status == RECENT_GENERATION_COMPLETED_STATUS,
|
||||
ChatGenerationTask.generation_mode.in_(task_mode_values),
|
||||
or_(_has_url(ChatGenerationTask.image_url), _has_url(ChatGenerationTask.video_url)),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(ranked_subquery)
|
||||
.where(ranked_subquery.c.row_num <= limit)
|
||||
.order_by(ranked_subquery.c.generation_mode.asc(), ranked_subquery.c.generated_time.desc())
|
||||
)
|
||||
|
||||
return [dict(row) for row in (await db.execute(stmt)).mappings().all()]
|
||||
|
||||
|
||||
async def _load_step_link_map(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
chat_task_ids: list[str],
|
||||
) -> dict[str, _StepLinkInfo]:
|
||||
if not chat_task_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ModuleGenerationStep.chat_task_id.label("chat_task_id"),
|
||||
ModuleGenerationStep.id.label("module_step_id"),
|
||||
ModuleGenerationStep.project_id.label("module_project_id"),
|
||||
ModuleGenerationStep.module.label("module"),
|
||||
ModuleGenerationStep.is_current.label("is_current"),
|
||||
ModuleGenerationStep.updated_at.label("updated_at"),
|
||||
)
|
||||
.where(
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
ModuleGenerationStep.chat_task_id.in_(chat_task_ids),
|
||||
ModuleGenerationStep.module.in_(
|
||||
[
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
ModuleGenerationStep.chat_task_id.asc(),
|
||||
ModuleGenerationStep.is_current.desc(),
|
||||
ModuleGenerationStep.updated_at.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
link_map: dict[str, _StepLinkInfo] = {}
|
||||
rows = (await db.execute(stmt)).mappings().all()
|
||||
for row in rows:
|
||||
chat_task_id = row["chat_task_id"]
|
||||
if not chat_task_id or chat_task_id in link_map:
|
||||
continue
|
||||
link_map[chat_task_id] = {
|
||||
"module_project_id": row["module_project_id"],
|
||||
"module_step_id": row["module_step_id"],
|
||||
"module": row["module"],
|
||||
}
|
||||
return link_map
|
||||
|
||||
|
||||
async def _load_shot_link_map(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
module_project_ids: list[str],
|
||||
) -> dict[str, _ShotLinkInfo]:
|
||||
if not module_project_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ShotReplicateSegment.module_project_id.label("module_project_id"),
|
||||
ShotReplicateSegment.id.label("shot_segment_id"),
|
||||
ShotReplicateSegment.task_set_id.label("shot_task_set_id"),
|
||||
)
|
||||
.where(
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
ShotReplicateSegment.module_project_id.in_(module_project_ids),
|
||||
)
|
||||
.order_by(ShotReplicateSegment.updated_at.desc())
|
||||
)
|
||||
|
||||
link_map: dict[str, _ShotLinkInfo] = {}
|
||||
rows = (await db.execute(stmt)).mappings().all()
|
||||
for row in rows:
|
||||
module_project_id = row["module_project_id"]
|
||||
if not module_project_id or module_project_id in link_map:
|
||||
continue
|
||||
link_map[module_project_id] = {
|
||||
"shot_task_set_id": row["shot_task_set_id"],
|
||||
"shot_segment_id": row["shot_segment_id"],
|
||||
}
|
||||
return link_map
|
||||
|
||||
|
||||
async def _build_chat_task_group_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]]:
|
||||
grouped: dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]] = {
|
||||
RecentGenerationModuleEnum.CHAT_AI: [],
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: [],
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE: [],
|
||||
}
|
||||
if not rows:
|
||||
return grouped
|
||||
|
||||
module_task_rows: list[dict[str, Any]] = []
|
||||
module_chat_task_ids: list[str] = []
|
||||
for row in rows:
|
||||
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
|
||||
if module in (
|
||||
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
|
||||
RecentGenerationModuleEnum.SHOT_REPLICATE,
|
||||
):
|
||||
module_task_rows.append(row)
|
||||
module_chat_task_ids.append(row["generation_id"])
|
||||
|
||||
step_link_map = await _load_step_link_map(db, chat_task_ids=module_chat_task_ids)
|
||||
|
||||
shot_project_ids = [
|
||||
step_info["module_project_id"]
|
||||
for row in module_task_rows
|
||||
if (step_info := step_link_map.get(row["generation_id"]))
|
||||
and step_info.get("module") == RecentGenerationModuleEnum.SHOT_REPLICATE.value
|
||||
and step_info.get("module_project_id")
|
||||
]
|
||||
shot_link_map = await _load_shot_link_map(db, module_project_ids=shot_project_ids)
|
||||
|
||||
for row in rows:
|
||||
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
|
||||
if not module:
|
||||
continue
|
||||
|
||||
step_info = step_link_map.get(row["generation_id"])
|
||||
shot_info = None
|
||||
if module == RecentGenerationModuleEnum.SHOT_REPLICATE and step_info:
|
||||
module_project_id = step_info.get("module_project_id")
|
||||
if module_project_id:
|
||||
shot_info = shot_link_map.get(module_project_id)
|
||||
|
||||
grouped[module].append(
|
||||
_build_item(
|
||||
generation_id=row["generation_id"],
|
||||
module=module,
|
||||
gen_type=row["gen_type"],
|
||||
image_url=row["image_url"],
|
||||
video_url=row["video_url"],
|
||||
video_cover_url=row["video_cover_url"],
|
||||
generated_time=row["generated_time"],
|
||||
step_info=step_info,
|
||||
shot_info=shot_info,
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
async def list_recent_generations(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
modules: Iterable[RecentGenerationModuleEnum] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> RecentGenerationGroupOut:
|
||||
"""获取当前用户各模块最近生成成功的图片/视频记录。"""
|
||||
|
||||
normalized_limit = _normalize_limit(limit)
|
||||
normalized_modules = _normalize_modules(modules)
|
||||
|
||||
response = RecentGenerationGroupOut()
|
||||
|
||||
if RecentGenerationModuleEnum.PROJECT in normalized_modules:
|
||||
response.project = await _list_project_recent_items(
|
||||
db,
|
||||
user_id=user_id,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
|
||||
chat_modules = [module for module in normalized_modules if module in RECENT_GENERATION_CHAT_TASK_MODULES]
|
||||
if chat_modules:
|
||||
chat_rows = await _list_chat_task_recent_rows(
|
||||
db,
|
||||
user_id=user_id,
|
||||
modules=chat_modules,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
chat_grouped = await _build_chat_task_group_items(db, rows=chat_rows)
|
||||
response.chat_ai = chat_grouped[RecentGenerationModuleEnum.CHAT_AI]
|
||||
response.hot_opening_replicate = chat_grouped[RecentGenerationModuleEnum.HOT_OPENING_REPLICATE]
|
||||
response.shot_replicate = chat_grouped[RecentGenerationModuleEnum.SHOT_REPLICATE]
|
||||
|
||||
return response
|
||||
@@ -334,8 +334,14 @@ async def _upload_to_juliang(
|
||||
file_content = f.read()
|
||||
image_signature = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
# data = {
|
||||
# "advertiser_id": advertiser_id,
|
||||
# "upload_type": "UPLOAD_BY_FILE",
|
||||
# "image_signature": image_signature,
|
||||
# "filename": filename,
|
||||
# }
|
||||
data = {
|
||||
"advertiser_id": advertiser_id,
|
||||
"local_account_id": advertiser_id,
|
||||
"upload_type": "UPLOAD_BY_FILE",
|
||||
"image_signature": image_signature,
|
||||
"filename": filename,
|
||||
@@ -345,7 +351,9 @@ async def _upload_to_juliang(
|
||||
"image_file": (filename, file_content, "image/png"),
|
||||
}
|
||||
|
||||
response = await douyin_api.upload_image_material(oauth_id, data, files)
|
||||
# 上传本地推图片
|
||||
response = await douyin_api.upload_local_image_material(oauth_id, data, files)
|
||||
#response = await douyin_api.upload_image_material(oauth_id, data, files)
|
||||
|
||||
if response["code"] != 0:
|
||||
return {
|
||||
@@ -416,8 +424,14 @@ async def _upload_to_juliang(
|
||||
file_content = f.read()
|
||||
video_signature = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
# data = {
|
||||
# "advertiser_id": advertiser_id,
|
||||
# "upload_type": "UPLOAD_BY_FILE",
|
||||
# "video_signature": video_signature,
|
||||
# "filename": filename,
|
||||
# }
|
||||
data = {
|
||||
"advertiser_id": advertiser_id,
|
||||
"local_account_id": advertiser_id,
|
||||
"upload_type": "UPLOAD_BY_FILE",
|
||||
"video_signature": video_signature,
|
||||
"filename": filename,
|
||||
@@ -428,6 +442,7 @@ async def _upload_to_juliang(
|
||||
}
|
||||
|
||||
response = await douyin_api.upload_video_material(oauth_id, data, files)
|
||||
#response = await douyin_api.upload_local_video_material(oauth_id, data, files)
|
||||
|
||||
if response["code"] != 0:
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -15,42 +14,13 @@ from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.utils.douyinApi import DouyinApi
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
import os
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("upload_queue")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class DailyRotatingFileHandler(logging.FileHandler):
|
||||
def __init__(self, directory, encoding=None):
|
||||
self.directory = directory
|
||||
filename = self._get_log_filename()
|
||||
super().__init__(filename, encoding=encoding)
|
||||
|
||||
def _get_log_filename(self):
|
||||
return os.path.join(self.directory, f"upload_queue-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log")
|
||||
|
||||
def emit(self, record):
|
||||
current_filename = self._get_log_filename()
|
||||
if self.baseFilename != current_filename:
|
||||
self.close()
|
||||
self.baseFilename = current_filename
|
||||
self.stream = self._open()
|
||||
super().emit(record)
|
||||
|
||||
|
||||
if not logger.handlers:
|
||||
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger = get_logger("upload_queue", "upload_queue")
|
||||
douyin_api = DouyinApi()
|
||||
|
||||
#上传素材队列,处理上传素材的任务
|
||||
@@ -58,7 +28,6 @@ class UploadQueue:
|
||||
def __init__(self):
|
||||
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
self.running = False
|
||||
|
||||
async def enqueue(self, task_id: str):
|
||||
"""Add a task to the queue."""
|
||||
await self.queue.put(task_id)
|
||||
@@ -308,6 +277,20 @@ async def _upload_to_juliang(
|
||||
else:
|
||||
filename = os.path.basename(storage_path)
|
||||
|
||||
#查询授权记录
|
||||
oauth = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
)
|
||||
)
|
||||
oauth = oauth.scalar_one_or_none()
|
||||
if not oauth:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "授权记录不存在"
|
||||
}
|
||||
account_role = oauth.account_role
|
||||
|
||||
if resource_type == "image":
|
||||
if resource.file_size_bytes > 5 * 1024 * 1024:
|
||||
return {
|
||||
@@ -323,17 +306,21 @@ async def _upload_to_juliang(
|
||||
image_signature = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
data = {
|
||||
"advertiser_id": advertiser_id,
|
||||
"upload_type": "UPLOAD_BY_FILE",
|
||||
"image_signature": image_signature,
|
||||
"filename": filename,
|
||||
}
|
||||
|
||||
files = {
|
||||
"image_file": (filename, file_content, "image/png"),
|
||||
}
|
||||
|
||||
response = await douyin_api.upload_image_material(oauth_id, data, files)
|
||||
#如果account_role授权角色包含:LOCAL,那么就是本地推接口,其他是广告千川接口
|
||||
if "LOCAL" in account_role:
|
||||
data["local_account_id"] = advertiser_id
|
||||
response = await douyin_api.upload_local_image_material(oauth_id, data, files)
|
||||
else:
|
||||
data["advertiser_id"] = advertiser_id
|
||||
response = await douyin_api.upload_image_material(oauth_id, data, files)
|
||||
|
||||
if response["code"] != 0:
|
||||
return {
|
||||
@@ -406,17 +393,20 @@ async def _upload_to_juliang(
|
||||
video_signature = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
data = {
|
||||
"advertiser_id": advertiser_id,
|
||||
"upload_type": "UPLOAD_BY_FILE",
|
||||
"video_signature": video_signature,
|
||||
"filename": filename,
|
||||
}
|
||||
|
||||
files = {
|
||||
"video_file": (filename, file_content, "video/mp4"),
|
||||
}
|
||||
|
||||
response = await douyin_api.upload_video_material(oauth_id, data, files)
|
||||
if "LOCAL" in account_role:
|
||||
data["local_account_id"] = advertiser_id
|
||||
response = await douyin_api.upload_local_video_material(oauth_id, data, files)
|
||||
else:
|
||||
data["advertiser_id"] = advertiser_id
|
||||
response = await douyin_api.upload_video_material(oauth_id, data, files)
|
||||
|
||||
if response["code"] != 0:
|
||||
return {
|
||||
@@ -653,4 +643,4 @@ async def _update_material_pre_test_status(
|
||||
)
|
||||
|
||||
|
||||
upload_queue = UploadQueue()
|
||||
upload_queue = UploadQueue()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
|
||||
async def get_oauth_account_list(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
advertiser_id: str | None = None,
|
||||
oauth_id: str | None = None,
|
||||
advertiser_name: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict:
|
||||
# 构建连表查询
|
||||
query = select(
|
||||
UserOAuthAccount.id,
|
||||
UserOAuthAccount.advertiser_id,
|
||||
UserOAuthAccount.advertiser_name,
|
||||
UserOAuthAccount.advertiser_role,
|
||||
UserOAuthAccount.oauth_id,
|
||||
UserOAuthAccount.created_at,
|
||||
UserOAuth.account_id,
|
||||
UserOAuth.account_name,
|
||||
UserOAuth.account_role,
|
||||
UserOAuth.account_username,
|
||||
UserOAuth.account_userid,
|
||||
UserOAuth.open_type,
|
||||
).join(
|
||||
UserOAuth,
|
||||
UserOAuth.id == UserOAuthAccount.oauth_id
|
||||
).where(
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
UserOAuthAccount.deleted_at.is_(None),
|
||||
UserOAuth.user_id == user_id, # 过滤当前登录用户
|
||||
)
|
||||
|
||||
# 添加筛选条件
|
||||
if advertiser_id:
|
||||
query = query.where(UserOAuthAccount.advertiser_id == advertiser_id)
|
||||
if oauth_id:
|
||||
query = query.where(UserOAuthAccount.oauth_id == oauth_id)
|
||||
if advertiser_name:
|
||||
query = query.where(UserOAuthAccount.advertiser_name.like(f"%{advertiser_name}%"))
|
||||
|
||||
# 查询总数
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 查询分页数据
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size).order_by(UserOAuthAccount.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
accounts = result.all()
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
# 转换为字典列表(或 Pydantic 实例列表)
|
||||
data = [
|
||||
{
|
||||
"id": row.id,
|
||||
"advertiser_id": row.advertiser_id,
|
||||
"advertiser_name": row.advertiser_name,
|
||||
"advertiser_role": row.advertiser_role,
|
||||
"oauth_id": row.oauth_id,
|
||||
"account_id": row.account_id,
|
||||
"account_name": row.account_name,
|
||||
"account_role": row.account_role,
|
||||
"account_username": row.account_username,
|
||||
"account_userid": row.account_userid,
|
||||
"open_type": row.open_type,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
for row in accounts
|
||||
]
|
||||
return {
|
||||
"data": data,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
async def delete_oauth_account(
|
||||
db: AsyncSession,
|
||||
account_id: str,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(UserOAuthAccount).join(
|
||||
UserOAuth,
|
||||
UserOAuth.id == UserOAuthAccount.oauth_id
|
||||
).where(
|
||||
UserOAuthAccount.id == account_id,
|
||||
UserOAuthAccount.deleted_at.is_(None),
|
||||
UserOAuth.user_id == user_id, # 过滤当前登录用户
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise ValueError("授权账户不存在")
|
||||
|
||||
# 软删除
|
||||
account.deleted_at = func.now()
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
@@ -10,6 +10,7 @@ from app.models.base import async_session
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response
|
||||
from app.services.image_gen import get_active_image_engine, download_image
|
||||
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||
from app.services.resource_accounting_service import (
|
||||
record_generation_record_generated_resource,
|
||||
safe_file_size,
|
||||
@@ -157,6 +158,7 @@ class TaskQueue:
|
||||
else:
|
||||
record.video_url = file_url
|
||||
record.video_tokens_used = poll_result.get("video_tokens", 0)
|
||||
await sync_generation_record_media_token_snapshot(db, record, provider_response=resp_data)
|
||||
record.status = "completed"
|
||||
record.generated_at = datetime.now()
|
||||
if record.video_url:
|
||||
@@ -235,6 +237,7 @@ class TaskQueue:
|
||||
else:
|
||||
record.image_url = remote_url
|
||||
record.image_tokens_used = poll_result.get("image_tokens", 0)
|
||||
await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result)
|
||||
record.status = "completed"
|
||||
record.generated_at = datetime.now()
|
||||
if record.image_url:
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
from concurrent.futures import Future, TimeoutError as FutureTimeoutError
|
||||
from typing import Awaitable, TypeVar
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_thread_local = threading.local()
|
||||
@@ -18,6 +21,28 @@ _single_loop_pid: int | None = None
|
||||
_single_loop_ready: threading.Event | None = None
|
||||
|
||||
|
||||
async def _dispose_async_resources() -> None:
|
||||
"""释放当前 async loop 内缓存的异步资源。
|
||||
|
||||
Celery soft time limit 会打断同步等待 future.result() 的线程;如果不主动
|
||||
cancel coroutine 并释放 engine/redis,后台 loop 里残留的协程可能继续占用
|
||||
SQLAlchemy QueuePool 连接,后续任务就会出现 QueuePool timeout。
|
||||
"""
|
||||
try:
|
||||
from app.services.redis_registry_service import close_registry_redis
|
||||
|
||||
await close_registry_redis()
|
||||
except Exception:
|
||||
logger.debug("关闭 Celery Redis registry 连接失败", exc_info=True)
|
||||
|
||||
try:
|
||||
from app.models.base import engine
|
||||
|
||||
await engine.dispose()
|
||||
except Exception:
|
||||
logger.debug("dispose Celery SQLAlchemy engine 失败", exc_info=True)
|
||||
|
||||
|
||||
def _runner_mode() -> str:
|
||||
mode = str(getattr(settings, "CELERY_ASYNC_RUNNER_MODE", "single_loop") or "single_loop").strip().lower()
|
||||
if mode not in {"single_loop", "direct"}:
|
||||
@@ -46,16 +71,18 @@ def _get_or_create_thread_local_loop() -> asyncio.AbstractEventLoop:
|
||||
def _single_loop_worker(loop: asyncio.AbstractEventLoop, ready: threading.Event) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
ready.set()
|
||||
loop.run_forever()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
|
||||
if pending:
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
|
||||
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
|
||||
if pending:
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
loop.run_until_complete(_dispose_async_resources())
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
|
||||
def _get_or_create_single_loop() -> asyncio.AbstractEventLoop:
|
||||
@@ -92,13 +119,28 @@ def _get_or_create_single_loop() -> asyncio.AbstractEventLoop:
|
||||
return _single_loop
|
||||
|
||||
|
||||
def _cancel_future_and_reset_loop(future: Future[T] | None, *, reason: str) -> None:
|
||||
"""取消当前协程并重置当前进程内 event loop。"""
|
||||
if future is not None and not future.done():
|
||||
future.cancel()
|
||||
try:
|
||||
future.result(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning("Celery async_runner 正在重置 event loop。reason=%s", reason)
|
||||
close_loop()
|
||||
|
||||
|
||||
def run_async(coro: Awaitable[T]) -> T:
|
||||
"""Celery 同步 task 调用异步协程的统一入口。
|
||||
|
||||
默认 single_loop 模式:
|
||||
- 一个 Celery 子进程只有一个专用 event loop;
|
||||
- 所有 asyncpg / redis.asyncio 操作都在这个 loop 内创建和使用;
|
||||
- 避免 got Future attached to a different loop。
|
||||
- 避免 got Future attached to a different loop;
|
||||
- 当 Celery soft time limit 打断 future.result() 时,主动 cancel 后台协程并
|
||||
释放连接池,避免 QueuePool 被残留任务长期占用。
|
||||
|
||||
降级 direct 模式:
|
||||
- 兼容旧的线程本地 loop 方案;
|
||||
@@ -106,7 +148,15 @@ def run_async(coro: Awaitable[T]) -> T:
|
||||
"""
|
||||
if _runner_mode() == "direct":
|
||||
loop = _get_or_create_thread_local_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
except BaseException:
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
loop.run_until_complete(_dispose_async_resources())
|
||||
finally:
|
||||
close_loop()
|
||||
raise
|
||||
|
||||
loop = _get_or_create_single_loop()
|
||||
try:
|
||||
@@ -118,7 +168,16 @@ def run_async(coro: Awaitable[T]) -> T:
|
||||
raise RuntimeError("run_async() 不能在 Celery async_runner 的事件循环内部被同步调用")
|
||||
|
||||
future: Future[T] = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return future.result()
|
||||
try:
|
||||
return future.result()
|
||||
except FutureTimeoutError:
|
||||
_cancel_future_and_reset_loop(future, reason="future_result_timeout")
|
||||
raise
|
||||
except BaseException:
|
||||
# Celery SoftTimeLimitExceeded/worker shutdown 等异常会从这里抛出。
|
||||
# 必须重置 loop,否则后台协程继续运行会拖住 DB 连接池。
|
||||
_cancel_future_and_reset_loop(future, reason="base_exception")
|
||||
raise
|
||||
|
||||
|
||||
def close_loop() -> None:
|
||||
@@ -130,8 +189,16 @@ def close_loop() -> None:
|
||||
loop = _single_loop
|
||||
thread = _single_loop_thread
|
||||
if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive():
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
try:
|
||||
cleanup_future = asyncio.run_coroutine_threadsafe(_dispose_async_resources(), loop)
|
||||
cleanup_future.result(timeout=5)
|
||||
except Exception:
|
||||
logger.debug("关闭 loop 前清理 async 资源失败", exc_info=True)
|
||||
try:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
except Exception:
|
||||
logger.debug("关闭 Celery async_runner loop 失败", exc_info=True)
|
||||
|
||||
_single_loop = None
|
||||
_single_loop_thread = None
|
||||
@@ -141,6 +208,17 @@ def close_loop() -> None:
|
||||
# 关闭 direct 降级模式的线程本地 loop。
|
||||
loop = getattr(_thread_local, "loop", None)
|
||||
if loop is not None and not loop.is_closed():
|
||||
loop.close()
|
||||
try:
|
||||
pending = [task for task in asyncio.all_tasks(loop) if not task.done()]
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
loop.run_until_complete(_dispose_async_resources())
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
except Exception:
|
||||
logger.debug("关闭 direct loop 前清理失败", exc_info=True)
|
||||
finally:
|
||||
loop.close()
|
||||
_thread_local.loop = None
|
||||
_thread_local.pid = None
|
||||
|
||||
@@ -26,6 +26,9 @@ CELERY_TASK_IMPORTS = (
|
||||
)
|
||||
|
||||
|
||||
RECOVERY_QUEUE = settings.CELERY_RECOVERY_QUEUE or "gen_recovery"
|
||||
|
||||
|
||||
def _derive_redis_db(url: str, db_no: int) -> str:
|
||||
if not url:
|
||||
return url
|
||||
@@ -55,6 +58,20 @@ if broker_url:
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
task_track_started=True,
|
||||
task_annotations={
|
||||
# 生成链路任务以数据库状态为准,不依赖 Celery result backend。
|
||||
# 这里忽略结果可避免任务误返回 ORM / 非 JSON 对象时触发结果序列化失败。
|
||||
# "generation.chatapi_create_generation_task": {"ignore_result": True},
|
||||
# "generation.poll_generation_task": {"ignore_result": True},
|
||||
# "generation.download_generation_result_task": {"ignore_result": True},
|
||||
"hot_opening.start_image_prompt_optimize": {"ignore_result": True},
|
||||
"hot_opening.start_video_prompt_optimize": {"ignore_result": True},
|
||||
"shot_replicate.analyze_original_video": {"ignore_result": True},
|
||||
"shot_replicate.analyze_custom_segment_video": {"ignore_result": True},
|
||||
"shot_replicate.split_one_segment": {"ignore_result": True},
|
||||
"shot_replicate.start_image_prompt_optimize": {"ignore_result": True},
|
||||
"shot_replicate.start_video_prompt_optimize": {"ignore_result": True},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
broker_transport_options={
|
||||
"visibility_timeout": 3600,
|
||||
@@ -73,10 +90,12 @@ if broker_url:
|
||||
"shot_replicate.split_one_segment": {"queue": "gen_result_download"},
|
||||
"shot_replicate.start_image_prompt_optimize": {"queue": "gen_chatapi_create"},
|
||||
"shot_replicate.start_video_prompt_optimize": {"queue": "gen_chatapi_create"},
|
||||
"shot_replicate.recover_split_tasks_once": {"queue": "gen_result_download"},
|
||||
"generation.recover_download_tasks_once": {"queue": "gen_result_download"},
|
||||
"generation.recover_generation_tasks_once": {"queue": "gen_result_download"},
|
||||
"module_async.recover_module_async_tasks_once": {"queue": "gen_result_download"},
|
||||
# 恢复扫描统一走独立队列,避免占用下载/轮询/创建业务 worker。
|
||||
"recovery.startup_recovery_once": {"queue": RECOVERY_QUEUE},
|
||||
"shot_replicate.recover_split_tasks_once": {"queue": RECOVERY_QUEUE},
|
||||
"generation.recover_download_tasks_once": {"queue": RECOVERY_QUEUE},
|
||||
"generation.recover_generation_tasks_once": {"queue": RECOVERY_QUEUE},
|
||||
"module_async.recover_module_async_tasks_once": {"queue": RECOVERY_QUEUE},
|
||||
"user_oauth.update_oauth_accounts": {"queue": "default"},
|
||||
"app.tasks.cleanup.*": {"queue": "default"},
|
||||
},
|
||||
@@ -86,7 +105,7 @@ else:
|
||||
|
||||
|
||||
async def _try_acquire_startup_recovery_lock() -> bool:
|
||||
"""任意 worker 启动时都可尝试抢恢复锁,避免依赖 hostname 命名。"""
|
||||
"""任意 worker 启动时都可尝试抢恢复投递锁,避免依赖 hostname 命名。"""
|
||||
from app.services.redis_registry_service import redis_acquire_lock
|
||||
|
||||
token = await redis_acquire_lock(
|
||||
@@ -103,9 +122,9 @@ def on_worker_ready(sender=None, **kwargs):
|
||||
|
||||
注意:
|
||||
- 不启用 Celery beat。
|
||||
- 不要求新增第四条启动命令。
|
||||
- 不再依赖 worker hostname 是否包含 gen_result_download。
|
||||
- 所有 worker 都尝试抢 Redis 锁,只有抢到锁的 worker 投递恢复任务。
|
||||
- 启动容灾保留,但只投递一个 recovery.startup_recovery_once 协调任务。
|
||||
- 协调任务走独立 gen_recovery 队列,串行扫描并把真实业务任务投回原队列。
|
||||
- 所有 worker 都尝试抢 Redis 投递锁,只有抢到锁的 worker 投递恢复任务。
|
||||
"""
|
||||
if celery_app is None:
|
||||
return
|
||||
@@ -122,39 +141,21 @@ def on_worker_ready(sender=None, **kwargs):
|
||||
return
|
||||
|
||||
try:
|
||||
from app.tasks.generation_recovery_tasks import (
|
||||
recover_download_tasks_once,
|
||||
recover_generation_tasks_once,
|
||||
)
|
||||
from app.tasks.shot_replicate_tasks import recover_split_tasks_once
|
||||
from app.tasks.module_async_recovery_tasks import recover_module_async_tasks_once_task
|
||||
from app.tasks.generation_recovery_tasks import startup_recovery_once
|
||||
|
||||
countdown = max(0, int(settings.DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS or 0))
|
||||
|
||||
recover_generation_tasks_once.apply_async(
|
||||
startup_recovery_once.apply_async(
|
||||
countdown=countdown,
|
||||
queue="gen_result_download",
|
||||
queue=RECOVERY_QUEUE,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
)
|
||||
recover_download_tasks_once.apply_async(
|
||||
countdown=countdown + 5,
|
||||
queue="gen_result_download",
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
logger.info(
|
||||
"启动容灾恢复协调任务已投递。queue=%s countdown=%s",
|
||||
RECOVERY_QUEUE,
|
||||
countdown,
|
||||
)
|
||||
recover_split_tasks_once.apply_async(
|
||||
countdown=countdown + 10,
|
||||
queue="gen_result_download",
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
)
|
||||
recover_module_async_tasks_once_task.apply_async(
|
||||
countdown=countdown + 15,
|
||||
queue="gen_result_download",
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
)
|
||||
|
||||
logger.info("启动容灾恢复任务已投递。countdown=%s", countdown)
|
||||
except Exception:
|
||||
logger.exception("启动容灾恢复任务投递失败")
|
||||
logger.exception("启动容灾恢复协调任务投递失败")
|
||||
|
||||
|
||||
@worker_process_init.connect
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.services.error_codes import extract_error_message
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.services.generation_provider_service import create_provider_task
|
||||
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
|
||||
from app.services.redis_registry_service import ensure_aware_utc
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
@@ -223,6 +224,7 @@ async def _run(task_id: str):
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=task.provider_response_json)
|
||||
|
||||
if task.remote_result_url and not task.seedance_task_id:
|
||||
# 同步图片路径:原 SDK 已经返回最终 URL。
|
||||
@@ -288,7 +290,13 @@ async def _run(task_id: str):
|
||||
if celery_app:
|
||||
@celery_app.task(name="generation.chatapi_create_generation_task", bind=True, max_retries=3, default_retry_delay=30)
|
||||
def chatapi_create_generation_task(self, task_id: str):
|
||||
return run_async(_run(task_id))
|
||||
try:
|
||||
return run_async(_run(task_id))
|
||||
except Exception as exc:
|
||||
# 只处理 run_async/连接池/worker 中断等基础设施异常;业务异常已在 _run 内落库并退款。
|
||||
retries = int(getattr(self.request, "retries", 0) or 0) + 1
|
||||
countdown = int(settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS or 30) * max(1, retries)
|
||||
raise self.retry(exc=exc, countdown=countdown)
|
||||
else:
|
||||
class _DisabledTask:
|
||||
def delay(self, *args, **kwargs):
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.tasks.async_runner import run_async
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import errno
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.generation_task import (
|
||||
ALLOWED_GENERATION_MODES,
|
||||
ChatGenerationPipelineStage,
|
||||
ChatGenerationTaskEventType,
|
||||
ChatGenerationTaskStatus,
|
||||
GenerationType,
|
||||
)
|
||||
from app.models.base import async_session
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.services.celery_download_recovery_service import (
|
||||
@@ -18,17 +31,17 @@ from app.services.error_codes import extract_error_message
|
||||
from app.services.generation_download_service import download_generation_result
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
|
||||
from app.services.resource_accounting_service import record_chat_task_generated_resource
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"}
|
||||
|
||||
DOWNLOAD_QUEUE = "gen_result_download"
|
||||
DOWNLOAD_STAGE_QUEUED = "download_queued"
|
||||
DOWNLOAD_STAGE_DOWNLOADING = "downloading"
|
||||
DOWNLOAD_STAGE_RETRY_WAITING = "retry_waiting"
|
||||
DOWNLOAD_STAGE_DONE = "done"
|
||||
DOWNLOAD_STAGE_FAILED = "download_failed"
|
||||
DOWNLOAD_STAGE_QUEUED = ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value
|
||||
DOWNLOAD_STAGE_DOWNLOADING = ChatGenerationPipelineStage.DOWNLOADING.value
|
||||
DOWNLOAD_STAGE_RETRY_WAITING = ChatGenerationPipelineStage.RETRY_WAITING.value
|
||||
DOWNLOAD_STAGE_DONE = ChatGenerationPipelineStage.DONE.value
|
||||
DOWNLOAD_STAGE_FAILED = ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
|
||||
RESULT_READY_STAGE = ChatGenerationPipelineStage.RESULT_READY.value
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -56,6 +69,15 @@ def _retry_at(attempt: int, now: datetime | None = None) -> datetime:
|
||||
return now + timedelta(seconds=max(1, base * max(1, attempt)))
|
||||
|
||||
|
||||
def _countdown_until(value: datetime | None, *, minimum: int = 1) -> int:
|
||||
target = ensure_aware_utc(value)
|
||||
if target is None:
|
||||
return minimum
|
||||
extra = int(getattr(settings, "DOWNLOAD_RETRY_COUNTDOWN_EXTRA_SECONDS", 1) or 0)
|
||||
seconds = (target - _now()).total_seconds()
|
||||
return max(minimum, math.ceil(seconds) + extra)
|
||||
|
||||
|
||||
def _is_expired(value: datetime | None, now: datetime | None = None) -> bool:
|
||||
value = ensure_aware_utc(value)
|
||||
if value is None:
|
||||
@@ -64,10 +86,10 @@ def _is_expired(value: datetime | None, now: datetime | None = None) -> bool:
|
||||
|
||||
|
||||
def _is_already_completed(task: ChatGenerationTask) -> bool:
|
||||
if task.status == "completed" or task.pipeline_stage == DOWNLOAD_STAGE_DONE:
|
||||
if task.gen_type == "image" and task.image_url:
|
||||
if task.status == ChatGenerationTaskStatus.COMPLETED.value or task.pipeline_stage == DOWNLOAD_STAGE_DONE:
|
||||
if task.gen_type == GenerationType.IMAGE.value and task.image_url:
|
||||
return True
|
||||
if task.gen_type == "video" and task.video_url:
|
||||
if task.gen_type == GenerationType.VIDEO.value and task.video_url:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -77,6 +99,61 @@ def _build_celery_task_id(task_id: str, attempt: int | None = None, reason: str
|
||||
return f"download:{task_id}:{int(attempt or 0)}:{safe_reason}:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _is_non_retryable_download_error(exc: Exception) -> bool:
|
||||
if not bool(getattr(settings, "DOWNLOAD_NON_RETRYABLE_LOCAL_ERRORS", True)):
|
||||
return False
|
||||
if isinstance(exc, PermissionError):
|
||||
return True
|
||||
if isinstance(exc, OSError) and getattr(exc, "errno", None) in {errno.EACCES, errno.EPERM, errno.ENOSPC, errno.EROFS, errno.ENAMETOOLONG}:
|
||||
return True
|
||||
message = str(exc).lower()
|
||||
non_retryable_fragments = (
|
||||
"permission denied",
|
||||
"no space left on device",
|
||||
"read-only file system",
|
||||
"file name too long",
|
||||
"invalid argument",
|
||||
)
|
||||
return any(fragment in message for fragment in non_retryable_fragments)
|
||||
|
||||
|
||||
async def _log_download_event(
|
||||
task: ChatGenerationTask | None = None,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
event_type: ChatGenerationTaskEventType | str,
|
||||
from_status: str | None = None,
|
||||
to_status: str | None = None,
|
||||
from_stage: str | None = None,
|
||||
to_stage: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: Any = None,
|
||||
) -> None:
|
||||
if not bool(getattr(settings, "DOWNLOAD_EVENT_VERBOSE_ENABLED", True)):
|
||||
# 成功/失败关键事件仍保留;只关闭 verbose skip 事件。
|
||||
critical = {
|
||||
ChatGenerationTaskEventType.DOWNLOAD_START.value,
|
||||
ChatGenerationTaskEventType.DOWNLOAD_SUCCESS.value,
|
||||
ChatGenerationTaskEventType.DOWNLOAD_FAILED.value,
|
||||
ChatGenerationTaskEventType.DOWNLOAD_FAILED_NON_RETRYABLE.value,
|
||||
ChatGenerationTaskEventType.DOWNLOAD_RETRY_WAITING.value,
|
||||
}
|
||||
event_value = event_type.value if hasattr(event_type, "value") else str(event_type)
|
||||
if event_value not in critical:
|
||||
return
|
||||
await log_task_event(
|
||||
task,
|
||||
task_id=task_id,
|
||||
event_type=event_type.value if hasattr(event_type, "value") else str(event_type),
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
from_stage=from_stage,
|
||||
to_stage=to_stage,
|
||||
message=message,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
async def _register_active_from_task(
|
||||
task: ChatGenerationTask,
|
||||
*,
|
||||
@@ -101,6 +178,62 @@ async def _register_active_from_task(
|
||||
await upsert_download_active(record_id=task.id, payload=payload, check_at=check_at)
|
||||
|
||||
|
||||
async def _apply_download_async(
|
||||
task: ChatGenerationTask,
|
||||
*,
|
||||
priority: int,
|
||||
countdown: int | None,
|
||||
reason: str,
|
||||
event_type: ChatGenerationTaskEventType,
|
||||
failed_event_type: ChatGenerationTaskEventType,
|
||||
) -> bool:
|
||||
if not celery_app:
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=failed_event_type,
|
||||
message="Celery 未启用,下载任务无法投递",
|
||||
detail={"queue": DOWNLOAD_QUEUE, "priority": priority, "countdown": countdown, "reason": reason},
|
||||
)
|
||||
return False
|
||||
try:
|
||||
download_generation_result_task.apply_async(
|
||||
args=[task.id],
|
||||
queue=DOWNLOAD_QUEUE,
|
||||
priority=priority,
|
||||
countdown=countdown,
|
||||
task_id=task.download_celery_task_id,
|
||||
)
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=event_type,
|
||||
to_stage=task.pipeline_stage,
|
||||
detail={
|
||||
"queue": DOWNLOAD_QUEUE,
|
||||
"priority": priority,
|
||||
"countdown": countdown,
|
||||
"download_celery_task_id": task.download_celery_task_id,
|
||||
"reason": reason,
|
||||
"attempt": task.download_attempt_count,
|
||||
},
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=failed_event_type,
|
||||
message=str(exc),
|
||||
detail={
|
||||
"queue": DOWNLOAD_QUEUE,
|
||||
"priority": priority,
|
||||
"countdown": countdown,
|
||||
"download_celery_task_id": task.download_celery_task_id,
|
||||
"reason": reason,
|
||||
"attempt": task.download_attempt_count,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def enqueue_download_task(
|
||||
db: AsyncSession,
|
||||
task: ChatGenerationTask,
|
||||
@@ -110,11 +243,17 @@ async def enqueue_download_task(
|
||||
countdown: int | None = None,
|
||||
) -> str | None:
|
||||
"""统一投递图片/视频下载任务,并同步 DB + Redis active 注册表。"""
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
reason = reason or ("recover" if recover else "normal")
|
||||
if not task:
|
||||
return None
|
||||
if task.status != "generating":
|
||||
if task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_INVALID_MODE, message="不支持的 generation_mode", detail={"reason": reason})
|
||||
return None
|
||||
if task.status != ChatGenerationTaskStatus.GENERATING.value:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NOT_GENERATING, message="任务不是 generating 状态", detail={"reason": reason, "status": task.status})
|
||||
return None
|
||||
if not task.remote_result_url:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NO_REMOTE_RESULT_URL, message="缺少 remote_result_url", detail={"reason": reason})
|
||||
return None
|
||||
|
||||
now = _now()
|
||||
@@ -122,9 +261,10 @@ async def enqueue_download_task(
|
||||
celery_task_id = _build_celery_task_id(
|
||||
task.id,
|
||||
attempt=task.download_attempt_count or task.retry_count or 0,
|
||||
reason=reason or ("recover" if recover else "normal"),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
old_stage = task.pipeline_stage
|
||||
task.pipeline_stage = DOWNLOAD_STAGE_QUEUED
|
||||
task.download_celery_task_id = celery_task_id
|
||||
task.download_enqueued_at = now
|
||||
@@ -137,13 +277,22 @@ async def enqueue_download_task(
|
||||
check_at = _queue_timeout_at(now)
|
||||
await _register_active_from_task(task, check_at=check_at, priority=priority, reason=reason)
|
||||
|
||||
if celery_app:
|
||||
download_generation_result_task.apply_async(
|
||||
args=[task.id],
|
||||
queue=DOWNLOAD_QUEUE,
|
||||
priority=priority,
|
||||
countdown=countdown,
|
||||
task_id=celery_task_id,
|
||||
await _apply_download_async(
|
||||
task,
|
||||
priority=priority,
|
||||
countdown=countdown,
|
||||
reason=reason,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE,
|
||||
failed_event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE_FAILED if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE_FAILED,
|
||||
)
|
||||
if old_stage != DOWNLOAD_STAGE_QUEUED:
|
||||
# 独立记录阶段变化的上下文,便于和真正投递事件对照。
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE,
|
||||
from_stage=old_stage,
|
||||
to_stage=DOWNLOAD_STAGE_QUEUED,
|
||||
detail={"reason": reason, "recover": recover, "check_at": check_at},
|
||||
)
|
||||
return celery_task_id
|
||||
|
||||
@@ -158,34 +307,81 @@ async def _reload_task(db: AsyncSession, task_id: str) -> ChatGenerationTask | N
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _reschedule_not_due_retry(task: ChatGenerationTask, *, now: datetime) -> None:
|
||||
countdown = _countdown_until(task.download_next_retry_at)
|
||||
await _register_active_from_task(
|
||||
task,
|
||||
check_at=ensure_aware_utc(task.download_next_retry_at) or (now + timedelta(seconds=countdown)),
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
reason="retry_waiting_not_due",
|
||||
)
|
||||
await _apply_download_async(
|
||||
task,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
countdown=countdown,
|
||||
reason="retry_waiting_not_due",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_ENQUEUE,
|
||||
failed_event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_ENQUEUE_FAILED,
|
||||
)
|
||||
|
||||
|
||||
async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> bool:
|
||||
now = _now()
|
||||
|
||||
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
if not task:
|
||||
return False
|
||||
if task.status != "generating":
|
||||
if task.generation_mode not in ALLOWED_GENERATION_MODES:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_INVALID_MODE, message="下载任务跳过:不支持的 generation_mode")
|
||||
return False
|
||||
if task.status != ChatGenerationTaskStatus.GENERATING.value:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NOT_GENERATING, message="下载任务跳过:任务不是 generating 状态", detail={"status": task.status, "stage": task.pipeline_stage})
|
||||
return False
|
||||
if _is_already_completed(task):
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_ALREADY_COMPLETED, message="下载任务跳过:任务已完成", detail={"status": task.status, "stage": task.pipeline_stage})
|
||||
return False
|
||||
if not task.remote_result_url:
|
||||
await _log_download_event(task, event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_NO_REMOTE_RESULT_URL, message="下载任务跳过:缺少 remote_result_url")
|
||||
return False
|
||||
|
||||
stage = task.pipeline_stage
|
||||
|
||||
if stage == DOWNLOAD_STAGE_DOWNLOADING:
|
||||
if not _is_expired(task.download_lease_until, now):
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_DOWNLOADING_LEASE_ALIVE,
|
||||
message="下载任务跳过:已有 downloading lease 且未过期",
|
||||
detail={"lease_until": task.download_lease_until, "download_celery_task_id": task.download_celery_task_id},
|
||||
)
|
||||
return False
|
||||
await log_task_event(
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_STUCK_RECOVER",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_STUCK_RECOVER,
|
||||
message=f"downloading lease 已过期,重新抢占下载。lease_until={task.download_lease_until}",
|
||||
)
|
||||
elif stage == DOWNLOAD_STAGE_RETRY_WAITING:
|
||||
if not _is_expired(task.download_next_retry_at, now):
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_NOT_DUE,
|
||||
message="下载重试提前触发,尚未到 next_retry_at,已重新投递延后重试",
|
||||
detail={
|
||||
"now": now,
|
||||
"download_next_retry_at": task.download_next_retry_at,
|
||||
"download_celery_task_id": task.download_celery_task_id,
|
||||
},
|
||||
)
|
||||
await _reschedule_not_due_retry(task, now=now)
|
||||
return False
|
||||
elif stage in (DOWNLOAD_STAGE_QUEUED, "result_ready"):
|
||||
elif stage in (DOWNLOAD_STAGE_QUEUED, RESULT_READY_STAGE):
|
||||
pass
|
||||
else:
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_STAGE_NOT_ALLOWED,
|
||||
message="下载任务跳过:当前阶段不允许下载",
|
||||
detail={"stage": stage, "status": task.status, "download_celery_task_id": task.download_celery_task_id},
|
||||
)
|
||||
return False
|
||||
|
||||
old_stage = stage
|
||||
@@ -207,9 +403,9 @@ async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> b
|
||||
reason="claim_download_lease",
|
||||
)
|
||||
|
||||
await log_task_event(
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_START",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_START,
|
||||
from_stage=old_stage,
|
||||
to_stage=DOWNLOAD_STAGE_DOWNLOADING,
|
||||
detail={
|
||||
@@ -244,9 +440,9 @@ async def _mark_retry_waiting(db: AsyncSession, task: ChatGenerationTask, exc: E
|
||||
reason="download_retry_waiting",
|
||||
)
|
||||
|
||||
await log_task_event(
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_RETRY_WAITING",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_WAITING,
|
||||
message=error_message,
|
||||
to_stage=DOWNLOAD_STAGE_RETRY_WAITING,
|
||||
detail={
|
||||
@@ -262,16 +458,53 @@ def _should_final_fail(task: ChatGenerationTask) -> bool:
|
||||
return int(task.download_attempt_count or task.retry_count or 0) >= int(settings.DOWNLOAD_TASK_MAX_ATTEMPTS or 3)
|
||||
|
||||
|
||||
async def _mark_download_failed(
|
||||
db: AsyncSession,
|
||||
task: ChatGenerationTask,
|
||||
*,
|
||||
exc: Exception,
|
||||
non_retryable: bool = False,
|
||||
) -> None:
|
||||
error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc)
|
||||
await mark_chat_generation_task_failed_and_refund_once(
|
||||
db,
|
||||
task=task,
|
||||
error_message=error_message,
|
||||
pipeline_stage=DOWNLOAD_STAGE_FAILED,
|
||||
)
|
||||
task.download_last_error = error_message
|
||||
task.download_lease_until = None
|
||||
task.download_next_retry_at = None
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
await remove_download_active(task.id)
|
||||
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_FAILED_NON_RETRYABLE if non_retryable else ChatGenerationTaskEventType.DOWNLOAD_FAILED,
|
||||
message=task.error_message,
|
||||
detail={
|
||||
"download_attempt_count": task.download_attempt_count,
|
||||
"max_attempts": settings.DOWNLOAD_TASK_MAX_ATTEMPTS,
|
||||
"non_retryable": non_retryable,
|
||||
"download_last_error": task.download_last_error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _run(task_id: str):
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
).with_for_update().limit(1)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
task = await _reload_task(db, task_id)
|
||||
if not task:
|
||||
await _log_download_event(
|
||||
task_id=task_id,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_TASK_MISSING,
|
||||
message="下载任务跳过:ChatGenerationTask 不存在或已软删",
|
||||
)
|
||||
await remove_download_active(task_id)
|
||||
return
|
||||
|
||||
claimed = await _claim_download_lease(db, task)
|
||||
@@ -283,15 +516,21 @@ async def _run(task_id: str):
|
||||
|
||||
task = await _reload_task(db, task_id)
|
||||
if not task:
|
||||
await _log_download_event(
|
||||
task_id=task_id,
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SKIP_TASK_MISSING,
|
||||
message="下载完成后任务不存在或已软删",
|
||||
)
|
||||
await remove_download_active(task_id)
|
||||
return
|
||||
|
||||
if task.gen_type == "image":
|
||||
if task.gen_type == GenerationType.IMAGE.value:
|
||||
task.image_url = downloaded.url
|
||||
else:
|
||||
task.video_url = downloaded.url
|
||||
task.video_cover_url = downloaded.cover_url
|
||||
|
||||
task.status = "completed"
|
||||
task.status = ChatGenerationTaskStatus.COMPLETED.value
|
||||
task.pipeline_stage = DOWNLOAD_STAGE_DONE
|
||||
task.generated_at = _now()
|
||||
task.retry_count = 0
|
||||
@@ -308,18 +547,18 @@ async def _run(task_id: str):
|
||||
remote_url=task.remote_result_url,
|
||||
generated_at=task.generated_at,
|
||||
)
|
||||
await sync_chat_generation_task_media_token_snapshot(db, task)
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
await remove_download_active(task.id)
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
|
||||
await log_task_event(
|
||||
await _log_download_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_SUCCESS",
|
||||
to_status="completed",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS,
|
||||
to_status=ChatGenerationTaskStatus.COMPLETED.value,
|
||||
to_stage=DOWNLOAD_STAGE_DONE,
|
||||
detail={
|
||||
"resource_url": downloaded.url,
|
||||
@@ -337,54 +576,35 @@ async def _run(task_id: str):
|
||||
|
||||
task = await _reload_task(db, task_id)
|
||||
if not task:
|
||||
await remove_download_active(task_id)
|
||||
return
|
||||
|
||||
if _should_final_fail(task):
|
||||
error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc)
|
||||
await mark_chat_generation_task_failed_and_refund_once(
|
||||
db,
|
||||
task=task,
|
||||
error_message=error_message,
|
||||
pipeline_stage=DOWNLOAD_STAGE_FAILED,
|
||||
)
|
||||
task.download_last_error = error_message
|
||||
task.download_lease_until = None
|
||||
task.download_next_retry_at = None
|
||||
await db.commit()
|
||||
|
||||
await remove_download_active(task.id)
|
||||
|
||||
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
|
||||
await notify_chat_generation_task_finished(db, task)
|
||||
await db.commit()
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="DOWNLOAD_FAILED",
|
||||
message=task.error_message,
|
||||
detail={
|
||||
"download_attempt_count": task.download_attempt_count,
|
||||
"max_attempts": settings.DOWNLOAD_TASK_MAX_ATTEMPTS,
|
||||
},
|
||||
)
|
||||
non_retryable = _is_non_retryable_download_error(exc)
|
||||
if non_retryable or _should_final_fail(task):
|
||||
await _mark_download_failed(db, task, exc=exc, non_retryable=non_retryable)
|
||||
else:
|
||||
next_retry_at = await _mark_retry_waiting(db, task, exc)
|
||||
|
||||
if celery_app:
|
||||
delay_seconds = max(1, int((next_retry_at - _now()).total_seconds()))
|
||||
download_generation_result_task.apply_async(
|
||||
args=[task.id],
|
||||
queue=DOWNLOAD_QUEUE,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
countdown=delay_seconds,
|
||||
task_id=task.download_celery_task_id,
|
||||
)
|
||||
delay_seconds = _countdown_until(next_retry_at)
|
||||
await _apply_download_async(
|
||||
task,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
countdown=delay_seconds,
|
||||
reason="download_exception_retry",
|
||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_ENQUEUE,
|
||||
failed_event_type=ChatGenerationTaskEventType.DOWNLOAD_RETRY_ENQUEUE_FAILED,
|
||||
)
|
||||
|
||||
|
||||
if celery_app:
|
||||
@celery_app.task(name="generation.download_generation_result_task", bind=True, max_retries=3, default_retry_delay=30)
|
||||
def download_generation_result_task(self, task_id: str):
|
||||
return run_async(_run(task_id))
|
||||
try:
|
||||
return run_async(_run(task_id))
|
||||
except Exception as exc:
|
||||
# 只重试 run_async/连接池/worker 中断等基础设施异常;下载业务异常已在 _run 内写入 retry_waiting。
|
||||
retries = int(getattr(self.request, "retries", 0) or 0) + 1
|
||||
countdown = int(settings.DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS or 30) * max(1, retries)
|
||||
raise self.retry(exc=exc, countdown=countdown)
|
||||
else:
|
||||
class _DisabledTask:
|
||||
def delay(self, *args, **kwargs):
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.services.error_codes import extract_error_message
|
||||
from app.services.generation_log_service import log_task_event, log_provider_call
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.services.generation_provider_service import poll_provider_task
|
||||
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
|
||||
from app.services.redis_registry_service import (
|
||||
datetime_to_epoch,
|
||||
ensure_aware_utc,
|
||||
@@ -193,7 +194,8 @@ async def _run(task_id: str):
|
||||
await remove_poll_active(task.id)
|
||||
return
|
||||
|
||||
if _deadline_expired(task):
|
||||
final_poll_before_timeout = _deadline_expired(task)
|
||||
if final_poll_before_timeout and not (task.seedance_task_id or task.provider_task_id):
|
||||
await _mark_timeout(db, task, message="任务轮询超时")
|
||||
return
|
||||
|
||||
@@ -201,6 +203,14 @@ async def _run(task_id: str):
|
||||
await _mark_failed(db, task, message="缺少外部任务ID")
|
||||
return
|
||||
|
||||
if final_poll_before_timeout:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="FINAL_POLL_BEFORE_TIMEOUT",
|
||||
message="任务已到 deadline,执行最后一次供应商查询后再判定超时",
|
||||
detail={"deadline_at": task.deadline_at, "stage": task.pipeline_stage},
|
||||
)
|
||||
|
||||
# 标记本次正在轮询,并登记 poll lease。
|
||||
# 如果 worker 在供应商接口调用过程中退出,启动恢复会在 lease 过期后重新投递。
|
||||
task.pipeline_stage = "polling"
|
||||
@@ -245,6 +255,7 @@ async def _run(task_id: str):
|
||||
task.video_tokens_used = poll_result.get("video_tokens", 0) or 0
|
||||
|
||||
task.provider_response_json = response_data
|
||||
await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=response_data)
|
||||
|
||||
if not task.remote_result_url:
|
||||
await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)
|
||||
@@ -272,6 +283,16 @@ async def _run(task_id: str):
|
||||
)
|
||||
return
|
||||
|
||||
if final_poll_before_timeout:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="FINAL_POLL_BEFORE_TIMEOUT_PENDING",
|
||||
message=f"最终查询后供应商仍未完成,按超时处理。status={status}",
|
||||
detail=poll_result,
|
||||
)
|
||||
await _mark_timeout(db, task, message="任务轮询超时")
|
||||
return
|
||||
|
||||
# 供应商仍在 pending / running 时,把阶段从 polling 改回 waiting_remote。
|
||||
# 同时登记下一次 poll active,Celery countdown 丢失时可由恢复任务拉起。
|
||||
task.pipeline_stage = "waiting_remote"
|
||||
@@ -307,6 +328,15 @@ async def _run(task_id: str):
|
||||
await remove_poll_active(task_id)
|
||||
return
|
||||
|
||||
if final_poll_before_timeout:
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="FINAL_POLL_BEFORE_TIMEOUT_ERROR",
|
||||
message=str(exc),
|
||||
)
|
||||
await _mark_timeout(db, task, message="任务轮询超时")
|
||||
return
|
||||
|
||||
task.retry_count = (task.retry_count or 0) + 1
|
||||
|
||||
if task.retry_count > settings.CHATAPI_ASYNC_MAX_RETRIES:
|
||||
@@ -337,7 +367,13 @@ async def _run(task_id: str):
|
||||
if celery_app:
|
||||
@celery_app.task(name="generation.poll_generation_task", bind=True, max_retries=3, default_retry_delay=30)
|
||||
def poll_generation_task(self, task_id: str):
|
||||
return run_async(_run(task_id))
|
||||
try:
|
||||
return run_async(_run(task_id))
|
||||
except Exception as exc:
|
||||
# 只重试基础设施异常;供应商失败/业务失败已在 _run 内处理。
|
||||
retries = int(getattr(self.request, "retries", 0) or 0) + 1
|
||||
countdown = int(settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS or 30) * max(1, retries)
|
||||
raise self.retry(exc=exc, countdown=countdown)
|
||||
else:
|
||||
class _DisabledTask:
|
||||
def delay(self, *args, **kwargs):
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# app/tasks/generation_recovery_tasks.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from app.config import settings
|
||||
from app.models.base import async_session
|
||||
from app.services.redis_registry_service import get_registry_redis, redis_acquire_lock, redis_release_lock
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
RECOVERY_QUEUE = settings.CELERY_RECOVERY_QUEUE or "gen_recovery"
|
||||
RecoveryRunner = Callable[[], Awaitable[Dict[str, Any]]]
|
||||
|
||||
|
||||
async def _run_download_once() -> Dict[str, Any]:
|
||||
from app.services.generation_recovery_service import recover_download_tasks_once
|
||||
@@ -22,16 +30,195 @@ async def _run_generation_once() -> Dict[str, Any]:
|
||||
return await recover_generation_tasks_once(db)
|
||||
|
||||
|
||||
async def _run_module_async_once() -> Dict[str, Any]:
|
||||
from app.services.module_async_recovery_service import recover_module_async_tasks_once
|
||||
|
||||
async with async_session() as db:
|
||||
return await recover_module_async_tasks_once(db)
|
||||
|
||||
|
||||
async def _run_shot_split_once() -> Dict[str, Any]:
|
||||
from app.services.shot_replicate_recovery_service import recover_shot_split_tasks_once
|
||||
|
||||
async with async_session() as db:
|
||||
return await recover_shot_split_tasks_once(db)
|
||||
|
||||
|
||||
async def _run_with_execution_lock(
|
||||
*,
|
||||
lock_key: str,
|
||||
log_context: str,
|
||||
runner: RecoveryRunner,
|
||||
) -> Dict[str, Any]:
|
||||
"""恢复任务执行锁。
|
||||
|
||||
worker_ready 的启动锁只保证“只投递一次”;如果 broker 中残留旧消息,
|
||||
或者人工手动触发恢复任务,仍可能并发执行。这里再加执行锁,避免多个
|
||||
恢复扫描同时扫库、抢行锁、抢连接池。
|
||||
"""
|
||||
redis = await get_registry_redis()
|
||||
token: str | None = None
|
||||
if redis is not None:
|
||||
token = await redis_acquire_lock(
|
||||
lock_key=lock_key,
|
||||
ttl_seconds=int(settings.CELERY_RECOVERY_TASK_LOCK_TTL_SECONDS or 600),
|
||||
log_context=log_context,
|
||||
)
|
||||
if not token:
|
||||
return {"skipped": "lock_held", "lock_key": lock_key}
|
||||
else:
|
||||
# Redis 不可用时仍允许 DB fallback 执行一次,避免恢复能力彻底失效。
|
||||
logger.warning("恢复任务执行锁不可用,降级直接执行。context=%s", log_context)
|
||||
|
||||
try:
|
||||
result = await runner()
|
||||
result["execution_lock"] = "lock_acquired" if token else "redis_unavailable_run_db_fallback"
|
||||
return result
|
||||
finally:
|
||||
if token:
|
||||
await redis_release_lock(lock_key=lock_key, token=token, log_context=log_context)
|
||||
|
||||
|
||||
async def _acquire_download_recovery_loop_lock() -> tuple[bool, str]:
|
||||
"""下载恢复循环锁。
|
||||
|
||||
Redis 不可用时降级为直接执行 DB fallback,避免恢复能力彻底失效;
|
||||
Redis 可用但锁被其他 worker 持有时,本轮跳过,不再重复投递下一轮。
|
||||
"""
|
||||
redis = await get_registry_redis()
|
||||
if redis is None:
|
||||
return True, "redis_unavailable_run_db_fallback"
|
||||
token = await redis_acquire_lock(
|
||||
lock_key=settings.DOWNLOAD_RECOVERY_LOOP_LOCK_KEY,
|
||||
ttl_seconds=int(settings.DOWNLOAD_RECOVERY_LOOP_LOCK_TTL_SECONDS or 55),
|
||||
log_context="download_recovery_loop",
|
||||
)
|
||||
return (bool(token), "lock_acquired" if token else "lock_held")
|
||||
|
||||
|
||||
def _schedule_next_download_recovery_loop() -> None:
|
||||
if not celery_app or not bool(getattr(settings, "DOWNLOAD_RECOVERY_LOOP_ENABLED", False)):
|
||||
return
|
||||
try:
|
||||
recover_download_tasks_once.apply_async(
|
||||
countdown=max(1, int(settings.DOWNLOAD_RECOVERY_INTERVAL_SECONDS or 60)),
|
||||
queue=RECOVERY_QUEUE,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("下载恢复循环下一轮投递失败")
|
||||
|
||||
|
||||
async def _run_startup_recovery_once() -> Dict[str, Any]:
|
||||
"""启动容灾协调器:串行跑恢复扫描。
|
||||
|
||||
真实业务任务仍投递回原队列:
|
||||
- 创建/提词/视频分析 -> gen_chatapi_create
|
||||
- provider poll -> gen_provider_poll
|
||||
- 下载/ffmpeg 切片 -> gen_result_download
|
||||
恢复扫描本身只走 gen_recovery,避免堵住业务 worker。
|
||||
"""
|
||||
return await _run_with_execution_lock(
|
||||
lock_key=settings.CELERY_RECOVERY_STARTUP_TASK_LOCK_KEY,
|
||||
log_context="startup_recovery_once",
|
||||
runner=_run_startup_recovery_steps,
|
||||
)
|
||||
|
||||
|
||||
async def _run_startup_recovery_steps() -> Dict[str, Any]:
|
||||
results: Dict[str, Any] = {}
|
||||
|
||||
steps: list[tuple[str, str, str, RecoveryRunner]] = [
|
||||
(
|
||||
"module_async",
|
||||
settings.MODULE_ASYNC_RECOVERY_LOCK_KEY,
|
||||
"module_async_recovery",
|
||||
_run_module_async_once,
|
||||
),
|
||||
(
|
||||
"shot_split",
|
||||
settings.SHOT_SPLIT_RECOVERY_LOCK_KEY,
|
||||
"shot_split_recovery",
|
||||
_run_shot_split_once,
|
||||
),
|
||||
(
|
||||
"generation",
|
||||
settings.GENERATION_RECOVERY_LOCK_KEY,
|
||||
"generation_recovery",
|
||||
_run_generation_once,
|
||||
),
|
||||
(
|
||||
"download",
|
||||
settings.DOWNLOAD_RECOVERY_LOCK_KEY,
|
||||
"download_recovery",
|
||||
_run_download_once,
|
||||
),
|
||||
]
|
||||
|
||||
for name, lock_key, log_context, runner in steps:
|
||||
try:
|
||||
results[name] = await _run_with_execution_lock(
|
||||
lock_key=lock_key,
|
||||
log_context=log_context,
|
||||
runner=runner,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("启动容灾步骤执行失败。step=%s", name)
|
||||
results[name] = {"error": str(exc)}
|
||||
|
||||
return {"steps": results}
|
||||
|
||||
|
||||
if celery_app:
|
||||
|
||||
@celery_app.task(name="generation.recover_download_tasks_once")
|
||||
def recover_download_tasks_once() -> Dict[str, Any]:
|
||||
return run_async(_run_download_once())
|
||||
@celery_app.task(
|
||||
name="recovery.startup_recovery_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def startup_recovery_once(self) -> Dict[str, Any]:
|
||||
return run_async(_run_startup_recovery_once())
|
||||
|
||||
|
||||
@celery_app.task(name="generation.recover_generation_tasks_once")
|
||||
def recover_generation_tasks_once() -> Dict[str, Any]:
|
||||
return run_async(_run_generation_once())
|
||||
@celery_app.task(
|
||||
name="generation.recover_download_tasks_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def recover_download_tasks_once(self) -> Dict[str, Any]:
|
||||
acquired, reason = run_async(_acquire_download_recovery_loop_lock())
|
||||
if not acquired:
|
||||
return {"skipped": reason}
|
||||
try:
|
||||
result = run_async(
|
||||
_run_with_execution_lock(
|
||||
lock_key=settings.DOWNLOAD_RECOVERY_LOCK_KEY,
|
||||
log_context="download_recovery",
|
||||
runner=_run_download_once,
|
||||
)
|
||||
)
|
||||
result["loop_lock"] = reason
|
||||
return result
|
||||
finally:
|
||||
_schedule_next_download_recovery_loop()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="generation.recover_generation_tasks_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def recover_generation_tasks_once(self) -> Dict[str, Any]:
|
||||
return run_async(
|
||||
_run_with_execution_lock(
|
||||
lock_key=settings.GENERATION_RECOVERY_LOCK_KEY,
|
||||
log_context="generation_recovery",
|
||||
runner=_run_generation_once,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -42,5 +229,6 @@ else:
|
||||
def apply_async(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
startup_recovery_once = _DisabledTask()
|
||||
recover_download_tasks_once = _DisabledTask()
|
||||
recover_generation_tasks_once = _DisabledTask()
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.tasks.celery_app import celery_app
|
||||
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
|
||||
|
||||
|
||||
async def _run_image_prompt(project_id: str, step_id: str | None = None):
|
||||
async def _run_image_prompt(project_id: str, step_id: str | None = None) -> None:
|
||||
lock_token: str | None = None
|
||||
if step_id:
|
||||
lock_token = await acquire_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
@@ -38,17 +38,17 @@ async def _run_image_prompt(project_id: str, step_id: str | None = None):
|
||||
await mark_active_started(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
if step_id:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
return result
|
||||
return None
|
||||
finally:
|
||||
if step_id:
|
||||
await release_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id, token=lock_token)
|
||||
|
||||
|
||||
async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
async def _run_video_prompt(project_id: str, step_id: str | None = None) -> None:
|
||||
lock_token: str | None = None
|
||||
if step_id:
|
||||
lock_token = await acquire_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
@@ -64,18 +64,18 @@ async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
await mark_active_started(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
if step_id:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
return result
|
||||
return None
|
||||
finally:
|
||||
if step_id:
|
||||
await release_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id, token=lock_token)
|
||||
|
||||
|
||||
if celery_app:
|
||||
@celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
@celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30, ignore_result=True)
|
||||
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的图片 AI 提词任务。
|
||||
|
||||
@@ -84,11 +84,12 @@ if celery_app:
|
||||
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
|
||||
"""
|
||||
try:
|
||||
return run_async(_run_image_prompt(project_id, step_id))
|
||||
run_async(_run_image_prompt(project_id, step_id))
|
||||
return None
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc) from exc
|
||||
|
||||
@celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
@celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30, ignore_result=True)
|
||||
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的视频 AI 提词任务。
|
||||
|
||||
@@ -97,7 +98,8 @@ if celery_app:
|
||||
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
|
||||
"""
|
||||
try:
|
||||
return run_async(_run_video_prompt(project_id, step_id))
|
||||
run_async(_run_video_prompt(project_id, step_id))
|
||||
return None
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc) from exc
|
||||
else:
|
||||
@@ -109,4 +111,4 @@ else:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
start_image_prompt_optimize = _DisabledTask()
|
||||
start_video_prompt_optimize = _DisabledTask()
|
||||
start_video_prompt_optimize = _DisabledTask()
|
||||
@@ -2,21 +2,49 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.models.base import async_session
|
||||
from app.services.module_async_recovery_service import recover_module_async_tasks_once
|
||||
from app.services.redis_registry_service import get_registry_redis, redis_acquire_lock, redis_release_lock
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
async def _run_recover_module_async_tasks_once() -> dict[str, Any]:
|
||||
async with async_session() as db:
|
||||
return await recover_module_async_tasks_once(db)
|
||||
redis = await get_registry_redis()
|
||||
token: str | None = None
|
||||
if redis is not None:
|
||||
token = await redis_acquire_lock(
|
||||
lock_key=settings.MODULE_ASYNC_RECOVERY_LOCK_KEY,
|
||||
ttl_seconds=int(settings.CELERY_RECOVERY_TASK_LOCK_TTL_SECONDS or 600),
|
||||
log_context="module_async_recovery",
|
||||
)
|
||||
if not token:
|
||||
return {"skipped": "lock_held", "lock_key": settings.MODULE_ASYNC_RECOVERY_LOCK_KEY}
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await recover_module_async_tasks_once(db)
|
||||
result["execution_lock"] = "lock_acquired" if token else "redis_unavailable_run_db_fallback"
|
||||
return result
|
||||
finally:
|
||||
if token:
|
||||
await redis_release_lock(
|
||||
lock_key=settings.MODULE_ASYNC_RECOVERY_LOCK_KEY,
|
||||
token=token,
|
||||
log_context="module_async_recovery",
|
||||
)
|
||||
|
||||
|
||||
if celery_app:
|
||||
|
||||
@celery_app.task(name="module_async.recover_module_async_tasks_once")
|
||||
def recover_module_async_tasks_once_task() -> dict[str, Any]:
|
||||
@celery_app.task(
|
||||
name="module_async.recover_module_async_tasks_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def recover_module_async_tasks_once_task(self) -> dict[str, Any]:
|
||||
return run_async(_run_recover_module_async_tasks_once())
|
||||
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -11,38 +9,9 @@ from app.models.base import async_session
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.utils.douyinApi import DouyinApi
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
#前测结果和上传素材属于一种任务,放到一起日志里边
|
||||
logger = logging.getLogger("upload_queue")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class DailyRotatingFileHandler(logging.FileHandler):
|
||||
def __init__(self, directory, encoding=None):
|
||||
self.directory = directory
|
||||
filename = self._get_log_filename()
|
||||
super().__init__(filename, encoding=encoding)
|
||||
|
||||
def _get_log_filename(self):
|
||||
return os.path.join(self.directory, f"pre_test_result_task-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log")
|
||||
|
||||
def emit(self, record):
|
||||
current_filename = self._get_log_filename()
|
||||
if self.baseFilename != current_filename:
|
||||
self.close()
|
||||
self.baseFilename = current_filename
|
||||
self.stream = self._open()
|
||||
super().emit(record)
|
||||
|
||||
|
||||
if not logger.handlers:
|
||||
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger = get_logger("pre_test_result_task", "pre_test_result_task")
|
||||
douyin_api = DouyinApi()
|
||||
|
||||
#获取前测结果并更新数据库,计划任务,每2分钟执行一次
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.tasks.celery_app import celery_app
|
||||
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
|
||||
|
||||
|
||||
async def _run_image_prompt(project_id: str, step_id: str | None = None):
|
||||
async def _run_image_prompt(project_id: str, step_id: str | None = None) -> None:
|
||||
lock_token: str | None = None
|
||||
if step_id:
|
||||
lock_token = await acquire_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
@@ -37,17 +37,17 @@ async def _run_image_prompt(project_id: str, step_id: str | None = None):
|
||||
await mark_active_started(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
if step_id:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
return result
|
||||
return None
|
||||
finally:
|
||||
if step_id:
|
||||
await release_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id, token=lock_token)
|
||||
|
||||
|
||||
async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
async def _run_video_prompt(project_id: str, step_id: str | None = None) -> None:
|
||||
lock_token: str | None = None
|
||||
if step_id:
|
||||
lock_token = await acquire_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
@@ -63,11 +63,11 @@ async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
await mark_active_started(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id)
|
||||
await db.commit()
|
||||
if step_id:
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
return result
|
||||
return None
|
||||
finally:
|
||||
if step_id:
|
||||
await release_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id, token=lock_token)
|
||||
@@ -75,7 +75,7 @@ async def _run_video_prompt(project_id: str, step_id: str | None = None):
|
||||
|
||||
if celery_app:
|
||||
|
||||
@celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
@celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30, ignore_result=True)
|
||||
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的图片 AI 提词任务。
|
||||
|
||||
@@ -84,12 +84,13 @@ if celery_app:
|
||||
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
|
||||
"""
|
||||
try:
|
||||
return run_async(_run_image_prompt(project_id, step_id))
|
||||
run_async(_run_image_prompt(project_id, step_id))
|
||||
return None
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc) from exc
|
||||
|
||||
|
||||
@celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
|
||||
@celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30, ignore_result=True)
|
||||
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
|
||||
"""手动触发后的视频 AI 提词任务。
|
||||
|
||||
@@ -98,7 +99,8 @@ if celery_app:
|
||||
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
|
||||
"""
|
||||
try:
|
||||
return run_async(_run_video_prompt(project_id, step_id))
|
||||
run_async(_run_video_prompt(project_id, step_id))
|
||||
return None
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc) from exc
|
||||
|
||||
@@ -112,4 +114,4 @@ else:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
start_image_prompt_optimize = _DisabledTask()
|
||||
start_video_prompt_optimize = _DisabledTask()
|
||||
start_video_prompt_optimize = _DisabledTask()
|
||||
@@ -20,7 +20,7 @@ from app.models.base import async_session
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
|
||||
from app.services.redis_registry_service import redis_acquire_lock, redis_release_lock
|
||||
from app.services.redis_registry_service import get_registry_redis, redis_acquire_lock, redis_release_lock
|
||||
from app.services.module_async_recovery_service import (
|
||||
OBJECT_SHOT_SEGMENT_ANALYSIS,
|
||||
OBJECT_SHOT_SPLIT_SEGMENT,
|
||||
@@ -561,8 +561,29 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
async def _run_recover_split_tasks_once() -> dict[str, Any]:
|
||||
from app.services.shot_replicate_recovery_service import recover_shot_split_tasks_once
|
||||
|
||||
async with async_session() as db:
|
||||
return await recover_shot_split_tasks_once(db)
|
||||
redis = await get_registry_redis()
|
||||
token: str | None = None
|
||||
if redis is not None:
|
||||
token = await redis_acquire_lock(
|
||||
lock_key=settings.SHOT_SPLIT_RECOVERY_LOCK_KEY,
|
||||
ttl_seconds=int(settings.CELERY_RECOVERY_TASK_LOCK_TTL_SECONDS or 600),
|
||||
log_context="shot_split_recovery",
|
||||
)
|
||||
if not token:
|
||||
return {"skipped": "lock_held", "lock_key": settings.SHOT_SPLIT_RECOVERY_LOCK_KEY}
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await recover_shot_split_tasks_once(db)
|
||||
result["execution_lock"] = "lock_acquired" if token else "redis_unavailable_run_db_fallback"
|
||||
return result
|
||||
finally:
|
||||
if token:
|
||||
await redis_release_lock(
|
||||
lock_key=settings.SHOT_SPLIT_RECOVERY_LOCK_KEY,
|
||||
token=token,
|
||||
log_context="shot_split_recovery",
|
||||
)
|
||||
|
||||
|
||||
if celery_app:
|
||||
@@ -582,8 +603,13 @@ if celery_app:
|
||||
return run_async(_run_analyze_custom_segment_video(segment_id))
|
||||
|
||||
|
||||
@celery_app.task(name="shot_replicate.recover_split_tasks_once")
|
||||
def recover_split_tasks_once() -> dict[str, Any]:
|
||||
@celery_app.task(
|
||||
name="shot_replicate.recover_split_tasks_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def recover_split_tasks_once(self) -> dict[str, Any]:
|
||||
return run_async(_run_recover_split_tasks_once())
|
||||
|
||||
else:
|
||||
|
||||
@@ -2,9 +2,6 @@ from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -14,37 +11,10 @@ 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
|
||||
|
||||
REDIS_KEY = "douyin:tokens"
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("token_refresh")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
def get_log_filename():
|
||||
return os.path.join(LOG_DIR, f"token_refresh-{datetime.now().strftime('%Y-%m-%d')}.log")
|
||||
|
||||
class DailyRotatingFileHandler(logging.FileHandler):
|
||||
def __init__(self, directory, encoding=None):
|
||||
self.directory = directory
|
||||
filename = get_log_filename()
|
||||
super().__init__(filename, encoding=encoding)
|
||||
|
||||
def emit(self, record):
|
||||
current_filename = get_log_filename()
|
||||
if self.baseFilename != current_filename:
|
||||
self.close()
|
||||
self.baseFilename = current_filename
|
||||
self.stream = self._open()
|
||||
super().emit(record)
|
||||
|
||||
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
logger.addHandler(handler)
|
||||
logger = get_logger("token_refresh", "token_refresh")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,24 @@ class DouyinApi:
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
#上传本地推图片
|
||||
async def upload_local_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://api.oceanengine.com/open_api/v3.0/local/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_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:
|
||||
@@ -34,7 +52,8 @@ class DouyinApi:
|
||||
oauth_id,
|
||||
url,
|
||||
'POST',
|
||||
options
|
||||
options,
|
||||
request_count = 3
|
||||
)
|
||||
|
||||
#上传视频素材
|
||||
@@ -52,9 +71,30 @@ class DouyinApi:
|
||||
oauth_id,
|
||||
url,
|
||||
'POST',
|
||||
options
|
||||
options,
|
||||
request_count = 3
|
||||
)
|
||||
|
||||
#上传本地推视频素材
|
||||
async def upload_local_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://api.oceanengine.com/open_api/v3.0/local/file/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:
|
||||
|
||||
@@ -11,6 +11,9 @@ 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("douyin_request", "douyin_request")
|
||||
|
||||
|
||||
class DouyinRequest:
|
||||
@@ -244,8 +247,6 @@ class DouyinRequest:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
|
||||
res = json.dumps(data) if 'data' in locals() else ''
|
||||
|
||||
options_log = {}
|
||||
if options:
|
||||
for key, value in options.items():
|
||||
@@ -254,10 +255,31 @@ class DouyinRequest:
|
||||
else:
|
||||
options_log[key] = value
|
||||
|
||||
raise RuntimeError(
|
||||
f'DouYin API request failed after 5 retries. '
|
||||
f'url:{url};oauthId:{oauth_id};options:{json.dumps(options_log)};response:{res}'
|
||||
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() 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('网络错误,稍后重试。')
|
||||
|
||||
# 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
|
||||
# raise RuntimeError(
|
||||
# f'DouYin API request failed after 5 retries. '
|
||||
# f'url:{url};oauthId:{oauth_id};options:{json.dumps(options_log)};response:{res}'
|
||||
# )
|
||||
# if code != 0:
|
||||
# raise ValueError(f'response:{res}')
|
||||
|
||||
# 无token请求
|
||||
async def request_with_context(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_logger(name: str, log_filename: str) -> logging.Logger:
|
||||
"""
|
||||
创建并配置一个每日滚动的日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
log_filename: 日志文件名(不含日期和扩展名)
|
||||
|
||||
Returns:
|
||||
配置好的日志记录器对象
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
class DailyRotatingFileHandler(logging.FileHandler):
|
||||
def __init__(self, directory, filename_prefix, encoding=None):
|
||||
self.directory = directory
|
||||
self.filename_prefix = filename_prefix
|
||||
filename = self._get_log_filename()
|
||||
super().__init__(filename, encoding=encoding)
|
||||
|
||||
def _get_log_filename(self):
|
||||
return os.path.join(self.directory, f"{self.filename_prefix}-{datetime.now().strftime('%Y-%m-%d')}.log")
|
||||
|
||||
def emit(self, record):
|
||||
current_filename = self._get_log_filename()
|
||||
if self.baseFilename != current_filename:
|
||||
self.close()
|
||||
self.baseFilename = current_filename
|
||||
self.stream = self._open()
|
||||
super().emit(record)
|
||||
|
||||
handler = DailyRotatingFileHandler(LOG_DIR, log_filename, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
logger.addHandler(handler)
|
||||
|
||||
return logger
|
||||
@@ -11,7 +11,7 @@ Requires-Dist: alembic>=1.14.0
|
||||
Requires-Dist: pydantic>=2.10.0
|
||||
Requires-Dist: pydantic-settings>=2.6.0
|
||||
Requires-Dist: pyjwt>=2.10.0
|
||||
Requires-Dist: passlib[bcrypt]>=1.7.4
|
||||
Requires-Dist: bcrypt>=4.0.0
|
||||
Requires-Dist: httpx>=0.28.0
|
||||
Requires-Dist: python-multipart>=0.0.17
|
||||
Requires-Dist: cryptography>=44.0.0
|
||||
@@ -22,6 +22,12 @@ Requires-Dist: redis>=5.2.0; extra == "redis"
|
||||
Provides-Extra: celery
|
||||
Requires-Dist: celery>=5.4.0; extra == "celery"
|
||||
Requires-Dist: redis>=5.2.0; extra == "celery"
|
||||
Provides-Extra: alipay
|
||||
Requires-Dist: alipay-sdk-python>=3.7.1160; extra == "alipay"
|
||||
Provides-Extra: wxpay
|
||||
Requires-Dist: wechatpayv3>=2.0.2; extra == "wxpay"
|
||||
Provides-Extra: volc
|
||||
Requires-Dist: volcengine-python-sdk>=1.1.0; extra == "volc"
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
||||
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
||||
|
||||
@@ -4,56 +4,196 @@ app/config.py
|
||||
app/dependencies.py
|
||||
app/main.py
|
||||
app/api/__init__.py
|
||||
app/api/admin/__init__.py
|
||||
app/api/admin/video_prompt_schema_config.py
|
||||
app/api/v1/__init__.py
|
||||
app/api/v1/admin.py
|
||||
app/api/v1/auth.py
|
||||
app/api/v1/captcha.py
|
||||
app/api/v1/contact.py
|
||||
app/api/v1/credits.py
|
||||
app/api/v1/generation.py
|
||||
app/api/v1/generation_ai.py
|
||||
app/api/v1/hot_opening_replicate.py
|
||||
app/api/v1/image_engines.py
|
||||
app/api/v1/industries.py
|
||||
app/api/v1/material_consumption.py
|
||||
app/api/v1/menu_configs.py
|
||||
app/api/v1/notifications.py
|
||||
app/api/v1/open_type.py
|
||||
app/api/v1/payments.py
|
||||
app/api/v1/pre_test_template.py
|
||||
app/api/v1/projects.py
|
||||
app/api/v1/recharge_packages.py
|
||||
app/api/v1/resources_material.py
|
||||
app/api/v1/shot_replicate.py
|
||||
app/api/v1/sms.py
|
||||
app/api/v1/test.py
|
||||
app/api/v1/upload_material.py
|
||||
app/api/v1/user_oauth.py
|
||||
app/api/v1/user_oauth_app.py
|
||||
app/api/v1/video_engines.py
|
||||
app/enums/__init__.py
|
||||
app/enums/common.py
|
||||
app/enums/credit_record.py
|
||||
app/enums/hot_opening_replicate.py
|
||||
app/enums/module_generation_flow.py
|
||||
app/enums/shot_replicate.py
|
||||
app/enums/token_usage.py
|
||||
app/enums/user.py
|
||||
app/enums/video_prompt_schema.py
|
||||
app/middleware/__init__.py
|
||||
app/middleware/anti_crawler.py
|
||||
app/middleware/logging.py
|
||||
app/middleware/rate_limit.py
|
||||
app/middleware/request_encrypt.py
|
||||
app/models/__init__.py
|
||||
app/models/base.py
|
||||
app/models/chat_generation_task.py
|
||||
app/models/chat_generation_task_event.py
|
||||
app/models/chat_provider_call_log.py
|
||||
app/models/contact_request.py
|
||||
app/models/credit_ratio.py
|
||||
app/models/credit_record.py
|
||||
app/models/generated_resource.py
|
||||
app/models/generation_record.py
|
||||
app/models/image_engine.py
|
||||
app/models/industry_config.py
|
||||
app/models/material_cost.py
|
||||
app/models/menu_config.py
|
||||
app/models/model_config.py
|
||||
app/models/module_generation_project.py
|
||||
app/models/module_generation_step.py
|
||||
app/models/notification.py
|
||||
app/models/notification_read.py
|
||||
app/models/open_type.py
|
||||
app/models/operation_log.py
|
||||
app/models/payment_order.py
|
||||
app/models/pre_test_template.py
|
||||
app/models/project.py
|
||||
app/models/recharge_package.py
|
||||
app/models/resources_material.py
|
||||
app/models/shot_replicate_segment.py
|
||||
app/models/shot_replicate_task_set.py
|
||||
app/models/system_config.py
|
||||
app/models/token_usage.py
|
||||
app/models/upload_task.py
|
||||
app/models/user.py
|
||||
app/models/user_oauth.py
|
||||
app/models/user_oauth_account.py
|
||||
app/models/user_oauth_app.py
|
||||
app/models/user_resource_month_stat.py
|
||||
app/models/user_resource_total_stat.py
|
||||
app/models/video_engine.py
|
||||
app/schemas/__init__.py
|
||||
app/schemas/admin.py
|
||||
app/schemas/auth.py
|
||||
app/schemas/captcha.py
|
||||
app/schemas/common.py
|
||||
app/schemas/contact.py
|
||||
app/schemas/credit.py
|
||||
app/schemas/credit_ratio.py
|
||||
app/schemas/generation.py
|
||||
app/schemas/generation_ai.py
|
||||
app/schemas/hot_opening_replicate.py
|
||||
app/schemas/image_engine.py
|
||||
app/schemas/industry.py
|
||||
app/schemas/menu.py
|
||||
app/schemas/notification.py
|
||||
app/schemas/open_type.py
|
||||
app/schemas/payment.py
|
||||
app/schemas/pre_test_template.py
|
||||
app/schemas/project.py
|
||||
app/schemas/recharge_package.py
|
||||
app/schemas/resources_material.py
|
||||
app/schemas/shot_replicate.py
|
||||
app/schemas/sms.py
|
||||
app/schemas/user.py
|
||||
app/schemas/user_oauth.py
|
||||
app/schemas/user_oauth_app.py
|
||||
app/schemas/video_engine.py
|
||||
app/schemas/video_prompt_schema_config.py
|
||||
app/services/__init__.py
|
||||
app/services/admin_credit_record_service.py
|
||||
app/services/auth.py
|
||||
app/services/captcha.py
|
||||
app/services/celery_download_recovery_service.py
|
||||
app/services/credit_ratio_service.py
|
||||
app/services/credit_record_meta_service.py
|
||||
app/services/credits.py
|
||||
app/services/error_codes.py
|
||||
app/services/generation_ai_service.py
|
||||
app/services/generation_billing_service.py
|
||||
app/services/generation_download_service.py
|
||||
app/services/generation_log_service.py
|
||||
app/services/generation_module_hook_service.py
|
||||
app/services/generation_prompt_service.py
|
||||
app/services/generation_provider_service.py
|
||||
app/services/generation_provider_types.py
|
||||
app/services/generation_recovery_service.py
|
||||
app/services/generation_refund_service.py
|
||||
app/services/generation_task_factory_service.py
|
||||
app/services/hot_opening_replicate_service.py
|
||||
app/services/hot_opening_video_prompt_service.py
|
||||
app/services/image_gen.py
|
||||
app/services/llm.py
|
||||
app/services/log_config.py
|
||||
app/services/material_consumption_queue.py
|
||||
app/services/material_consumption_service.py
|
||||
app/services/module_async_recovery_service.py
|
||||
app/services/module_generation_flow_base_service.py
|
||||
app/services/module_generation_log_service.py
|
||||
app/services/module_generation_step_common_service.py
|
||||
app/services/module_generation_step_update_service.py
|
||||
app/services/notification.py
|
||||
app/services/operation_log.py
|
||||
app/services/payment.py
|
||||
app/services/pre_test_template_service.py
|
||||
app/services/provider_limit.py
|
||||
app/services/redis_registry_service.py
|
||||
app/services/resource_accounting_service.py
|
||||
app/services/resource_signed_url_service.py
|
||||
app/services/resources_material_service.py
|
||||
app/services/shot_replicate_flow_service.py
|
||||
app/services/shot_replicate_recovery_service.py
|
||||
app/services/shot_replicate_taskset_service.py
|
||||
app/services/shot_video_analysis_service.py
|
||||
app/services/shot_video_split_service.py
|
||||
app/services/sms.py
|
||||
app/services/upload_material_service.py
|
||||
app/services/upload_queue.py
|
||||
app/services/upload_video_asset_service.py
|
||||
app/services/user_oauth_app_service.py
|
||||
app/services/user_oauth_service.py
|
||||
app/services/video_cover_service.py
|
||||
app/services/video_gen.py
|
||||
app/services/video_prompt_schema_config_service.py
|
||||
app/services/video_queue.py
|
||||
app/services/video_url.py
|
||||
app/tasks/__init__.py
|
||||
app/tasks/async_runner.py
|
||||
app/tasks/celery_app.py
|
||||
app/tasks/cleanup.py
|
||||
app/tasks/generation_create_tasks.py
|
||||
app/tasks/generation_download_tasks.py
|
||||
app/tasks/generation_poll_tasks.py
|
||||
app/tasks/generation_recovery_tasks.py
|
||||
app/tasks/hot_opening_replicate_tasks.py
|
||||
app/tasks/material_consumption_task.py
|
||||
app/tasks/module_async_recovery_tasks.py
|
||||
app/tasks/pre_test_result_task.py
|
||||
app/tasks/shot_replicate_flow_tasks.py
|
||||
app/tasks/shot_replicate_tasks.py
|
||||
app/tasks/token_refresh_task.py
|
||||
app/tasks/user_oauth_tasks.py
|
||||
app/tasks/video_generation.py
|
||||
app/utils/__init__.py
|
||||
app/utils/area.py
|
||||
app/utils/douyinApi.py
|
||||
app/utils/douyinRequest.py
|
||||
app/utils/exceptions.py
|
||||
app/utils/id_gen.py
|
||||
app/utils/logger.py
|
||||
app/utils/redis.py
|
||||
app/utils/security.py
|
||||
videogen_api.egg-info/PKG-INFO
|
||||
|
||||
@@ -6,11 +6,14 @@ alembic>=1.14.0
|
||||
pydantic>=2.10.0
|
||||
pydantic-settings>=2.6.0
|
||||
pyjwt>=2.10.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt>=4.0.0
|
||||
httpx>=0.28.0
|
||||
python-multipart>=0.0.17
|
||||
cryptography>=44.0.0
|
||||
|
||||
[alipay]
|
||||
alipay-sdk-python>=3.7.1160
|
||||
|
||||
[celery]
|
||||
celery>=5.4.0
|
||||
redis>=5.2.0
|
||||
@@ -25,3 +28,9 @@ asyncpg>=0.30.0
|
||||
|
||||
[redis]
|
||||
redis>=5.2.0
|
||||
|
||||
[volc]
|
||||
volcengine-python-sdk>=1.1.0
|
||||
|
||||
[wxpay]
|
||||
wechatpayv3>=2.0.2
|
||||
|
||||
@@ -12,6 +12,11 @@ node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
+442
File diff suppressed because one or more lines are too long
-442
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-C49YKZ--.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DyH2vHpW.css">
|
||||
<script type="module" crossorigin src="/assets/index-1RNaKjB1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-xCZbcxht.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -25,6 +25,9 @@ import RemoveRw from './pages/RemoveRw';
|
||||
import HomePage from './pages/HomePage';
|
||||
// import RemoveFenbu from './pages/RemoveFenbu';
|
||||
import ConsumePage from './pages/ConsumePage';
|
||||
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
|
||||
import PopularPage from './pages/PopularPage';
|
||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -111,8 +114,11 @@ const App = () => {
|
||||
<Route path="generated" element={<GeneratedRecord />} />
|
||||
<Route path="pretest" element={<PreTest />} />
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt } from './crypto';
|
||||
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption for API request/response.
|
||||
* Uses Web Crypto API with a shared symmetric key.
|
||||
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
|
||||
*/
|
||||
|
||||
const ALGO = 'AES-GCM';
|
||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
||||
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return typeof window !== 'undefined' &&
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.subtle !== 'undefined';
|
||||
}
|
||||
|
||||
async function getCryptoKey(): Promise<CryptoKey> {
|
||||
if (cryptoKey) return cryptoKey;
|
||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
|
||||
}
|
||||
|
||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||
if (keyBytes.length !== 32) {
|
||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Encryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function decrypt(cipherB64: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Decryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const cipherBytes = combined.slice(IV_LENGTH);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
}
|
||||
@@ -414,11 +414,19 @@ export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
|
||||
export interface JuliangCallbackParams {
|
||||
auth_code: string;
|
||||
state: string;
|
||||
app_id?: string;
|
||||
material_auth_status?: string;
|
||||
scope?: string;
|
||||
uid?: string;
|
||||
}
|
||||
export async function juliang_callback(params: JuliangCallbackParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('auth_code', params.auth_code);
|
||||
query.set('state', params.state);
|
||||
if (params.app_id) query.set('app_id', params.app_id);
|
||||
if (params.material_auth_status) query.set('material_auth_status', params.material_auth_status);
|
||||
if (params.scope) query.set('scope', params.scope);
|
||||
if (params.uid) query.set('uid', params.uid);
|
||||
return api.get(`/user-oauth/juliang_callback?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -658,6 +666,19 @@ export async function getMaterialConsumptionFields(): Promise<any> {
|
||||
return api.get('/material-consumption/fields');
|
||||
}
|
||||
|
||||
// ── Contact ────────────────────────────────────────────────
|
||||
export interface ContactRequestParams {
|
||||
phone: string;
|
||||
company_name: string;
|
||||
industry: string;
|
||||
name: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function createContactRequest(params: ContactRequestParams): Promise<any> {
|
||||
return api.post('/contact/request', params);
|
||||
}
|
||||
|
||||
// 查询上传素材列表
|
||||
export interface ResourcesMaterialListParams {
|
||||
advertiser_id?: string;
|
||||
@@ -682,9 +703,19 @@ export async function getResourcesMaterialList(params: ResourcesMaterialListPara
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// home 获取各模块媒体
|
||||
export async function getmedit(limit:number): Promise<any> {
|
||||
return api.get(`/recent-generations?limit=${limit}&modules=project&modules=chat_ai&modules=hot_opening_replicate&modules=shot_replicate`);
|
||||
}
|
||||
export interface OpenTypeItem {
|
||||
id: string;
|
||||
openType: number;
|
||||
typeName: string;
|
||||
description: string;
|
||||
thumb?: string;
|
||||
}
|
||||
|
||||
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
|
||||
return api.get('/open-type/open_type_all');
|
||||
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
.desktop-sidebar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.desktop-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobile-menu-item:hover {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.mobile-menu-item-active {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%) !important;
|
||||
}
|
||||
|
||||
.mobile-menu-group {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile-menu-group:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mobile-menu-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-menu-label {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mobile-submenu {
|
||||
padding-left: 12px;
|
||||
background: rgba(248, 250, 252, 0.5);
|
||||
margin: 0 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-submenu-item {
|
||||
padding: 10px 16px 10px 20px;
|
||||
margin: 1px 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-recharge-item {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.mobile-recharge-item:hover {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
|
||||
}
|
||||
|
||||
.contact-button-wrapper {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.contact-tooltip {
|
||||
position: absolute;
|
||||
right: 64px;
|
||||
bottom: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #1e293b;
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.contact-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.contact-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.desktop-sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.desktop-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mobile-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.mobile-header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.mobile-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-credits-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-radius: 20px;
|
||||
color: #6366f1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-credits-badge:hover {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
display: block;
|
||||
padding-top: 56px;
|
||||
padding-bottom: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.mobile-content > div {
|
||||
margin: 12px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
min-height: calc(100vh - 104px);
|
||||
}
|
||||
|
||||
.contact-button-wrapper {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
.contact-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contact-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.recharge-modal .ant-modal {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
margin: 16px !important;
|
||||
}
|
||||
|
||||
.contact-modal .ant-modal {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
margin: 16px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } from 'antd';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
@@ -53,11 +53,17 @@ import {
|
||||
ApiOutlined,
|
||||
DatabaseOutlined,
|
||||
CloudServerOutlined,
|
||||
MessageOutlined,
|
||||
DownOutlined,
|
||||
InfoOutlined,
|
||||
MenuOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} 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 } from '../../api';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import './AppLayout.css';
|
||||
|
||||
interface MenuConfig {
|
||||
id: string;
|
||||
@@ -134,8 +140,9 @@ const AppLayout: React.FC = () => {
|
||||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
|
||||
const [siteName, setSiteName] = useState(() => {
|
||||
const cached = localStorage.getItem('siteInfo');
|
||||
const name = cached ? JSON.parse(cached).siteName || '' : '';
|
||||
@@ -162,17 +169,18 @@ const AppLayout: React.FC = () => {
|
||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [countdown, setCountdown] = useState(180); // 默认180秒超时
|
||||
const [countdown, setCountdown] = useState(180);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentOrderNoRef = useRef<string | null>(null);
|
||||
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const [contactForm] = Form.useForm();
|
||||
const [contactHovered, setContactHovered] = useState(false);
|
||||
const [submittingContact, setSubmittingContact] = useState(false);
|
||||
|
||||
// LocalStorage keys
|
||||
const PENDING_ORDER_KEY = 'pending_payment_order';
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo().then(info => {
|
||||
const name = info.siteName || '民众智创';
|
||||
@@ -205,17 +213,14 @@ const AppLayout: React.FC = () => {
|
||||
}).catch(() => { });
|
||||
};
|
||||
|
||||
// 检查并恢复待处理的支付订单
|
||||
useEffect(() => {
|
||||
const checkPendingOrder = async () => {
|
||||
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
|
||||
if (savedOrderStr) {
|
||||
try {
|
||||
const savedOrder = JSON.parse(savedOrderStr);
|
||||
// 查询订单状态
|
||||
const order = await getPaymentOrder(savedOrder.orderNo);
|
||||
if (order.status === 'pending') {
|
||||
// 订单仍然待支付,恢复弹窗
|
||||
setCurrentPaymentInfo({
|
||||
price: savedOrder.price,
|
||||
credits: savedOrder.credits,
|
||||
@@ -223,7 +228,6 @@ const AppLayout: React.FC = () => {
|
||||
method: savedOrder.method,
|
||||
});
|
||||
currentOrderNoRef.current = savedOrder.orderNo;
|
||||
// 计算剩余时间
|
||||
const now = Date.now();
|
||||
const createdAt = new Date(savedOrder.createdAt).getTime();
|
||||
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
|
||||
@@ -234,20 +238,16 @@ const AppLayout: React.FC = () => {
|
||||
setQrCodeModalOpen(true);
|
||||
startPolling(savedOrder.orderNo, remainingSeconds);
|
||||
} else {
|
||||
// 已超时,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} else if (order.status === 'paid') {
|
||||
// 已支付
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
} else {
|
||||
// 订单已取消或其他状态,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// 查询失败,清除
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
}
|
||||
@@ -258,21 +258,7 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
// Filter by user's allowed menus if set
|
||||
if (user?.allowedMenus && user.allowedMenus.length > 0) {
|
||||
const allowed = new Set(user.allowedMenus);
|
||||
const groupIds = new Set<string>();
|
||||
items.forEach((m: any) => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (pid && allowed.has(m.path)) groupIds.add(pid);
|
||||
});
|
||||
items = items.filter((m: any) => {
|
||||
const mt = m.menu_type ?? m.menuType;
|
||||
if (mt === 'group' && groupIds.has(m.id)) return true;
|
||||
return allowed.has(m.path);
|
||||
});
|
||||
}
|
||||
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
setMenuItems(items);
|
||||
}).catch(() => { });
|
||||
getRechargePackages().then(data => {
|
||||
@@ -280,7 +266,6 @@ const AppLayout: React.FC = () => {
|
||||
}).catch(() => { });
|
||||
getPaymentMethods().then(data => {
|
||||
setEnabledMethods(data);
|
||||
// Auto-select the first enabled method
|
||||
if (data.alipay) setPaymentMethod('alipay');
|
||||
else if (data.wechat) setPaymentMethod('wechat');
|
||||
}).catch(() => { });
|
||||
@@ -290,6 +275,47 @@ const AppLayout: React.FC = () => {
|
||||
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
||||
const sidebarW = SIDEBAR_W;
|
||||
|
||||
const childMap: Record<string, MenuConfig[]> = {};
|
||||
menuItems.forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (pid) {
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
}
|
||||
});
|
||||
|
||||
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
|
||||
topLevelItems.sort((a, b) => {
|
||||
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
|
||||
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
Object.keys(childMap).forEach(key => {
|
||||
childMap[key].sort((a, b) => {
|
||||
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
|
||||
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
});
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
const handleMobileMenuClick = (item: MenuConfig) => {
|
||||
const menuType = item.menu_type ?? item.menuType;
|
||||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||||
|
||||
if (menuType === 'group' || hasChildren) {
|
||||
setMobileExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[item.id]: !prev[item.id]
|
||||
}));
|
||||
} else if (item.path) {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems = [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
||||
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
|
||||
@@ -297,7 +323,6 @@ const AppLayout: React.FC = () => {
|
||||
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
|
||||
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
|
||||
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
|
||||
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ type: 'divider' as const },
|
||||
@@ -318,7 +343,7 @@ const AppLayout: React.FC = () => {
|
||||
await pwdForm.validateFields();
|
||||
message.success('密码修改成功(演示)');
|
||||
setPwdModalOpen(false); pwdForm.resetFields();
|
||||
} catch { /* validation */ }
|
||||
} catch { }
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -330,6 +355,31 @@ const AppLayout: React.FC = () => {
|
||||
setRechargeModalOpen(true);
|
||||
};
|
||||
|
||||
const handleContactSubmit = async () => {
|
||||
if (!user) {
|
||||
message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const values = await contactForm.validateFields();
|
||||
setSubmittingContact(true);
|
||||
await createContactRequest({
|
||||
phone: values.phone,
|
||||
company_name: values.companyName,
|
||||
industry: values.industry,
|
||||
name: values.name,
|
||||
message: values.message,
|
||||
});
|
||||
message.success('提交成功,我们会尽快与您联系');
|
||||
setContactModalOpen(false);
|
||||
contactForm.resetFields();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '提交失败');
|
||||
} finally {
|
||||
setSubmittingContact(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
@@ -345,7 +395,6 @@ const AppLayout: React.FC = () => {
|
||||
stopPolling();
|
||||
setCountdown(timeoutSeconds);
|
||||
|
||||
// 订单状态轮询(每2秒查询一次,只查询当前订单
|
||||
const pollingTimer = setInterval(async () => {
|
||||
try {
|
||||
const order = await getPaymentOrder(orderNo);
|
||||
@@ -364,16 +413,13 @@ const AppLayout: React.FC = () => {
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, 2000);
|
||||
pollingTimerRef.current = pollingTimer;
|
||||
|
||||
// 倒计时
|
||||
const countdownTimer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
// 超时自动取消
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) {
|
||||
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
|
||||
@@ -392,9 +438,82 @@ const AppLayout: React.FC = () => {
|
||||
countdownTimerRef.current = countdownTimer;
|
||||
}, [stopPolling]);
|
||||
|
||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||||
const menuType = item.menu_type ?? item.menuType;
|
||||
const isGroup = menuType === 'group';
|
||||
const isExpanded = collapsedGroups[item.id] !== true;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isGroup || hasChildren) {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
[item.id]: !prev[item.id]
|
||||
}));
|
||||
} else if (item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 10,
|
||||
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
|
||||
borderRadius: 12, margin: '1px 4px', cursor: 'pointer',
|
||||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? '#4f46e5' : (isGroup ? '#94a3b8' : '#475569'),
|
||||
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isGroup && !isActive) {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isGroup && (
|
||||
<span style={{
|
||||
fontSize: depth > 0 ? 14 : 16,
|
||||
flexShrink: 0,
|
||||
color: isActive ? '#6366f1' : '#64748b',
|
||||
}}>{menuIcon}</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', flex: 1, textAlign: 'left', fontWeight: isGroup ? 600 : (isActive ? 600 : 400), fontSize: isGroup ? 12 : (depth > 0 ? 13 : 14), textTransform: isGroup ? 'uppercase' : 'none', letterSpacing: isGroup ? 0.5 : 0 }}>
|
||||
{item.label}
|
||||
</span>
|
||||
{(isGroup || hasChildren) && (
|
||||
<span style={{
|
||||
fontSize: isGroup ? 12 : 14,
|
||||
color: '#cbd5e1',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}>
|
||||
<RightOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isGroup || hasChildren) && isExpanded && (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="desktop-sidebar" style={{
|
||||
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
||||
@@ -405,7 +524,6 @@ const AppLayout: React.FC = () => {
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
height: 80, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
@@ -445,76 +563,10 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||
{/* {!collapsed && (
|
||||
<div style={{ color: 'rgba(0,0,0,0.3)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
|
||||
导航
|
||||
</div>
|
||||
)} */}
|
||||
{(() => {
|
||||
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
|
||||
const pages = menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group');
|
||||
const childMap: Record<string, MenuConfig[]> = {};
|
||||
pages.filter(m => m.parent_id ?? m.parentId).forEach(m => {
|
||||
const pid = (m.parent_id ?? m.parentId) as string;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
const topLevel = pages.filter(m => !(m.parent_id ?? m.parentId));
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||||
return (
|
||||
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 12,
|
||||
padding: depth > 0 ? '8px 14px 8px 36px' : '10px 16px',
|
||||
borderRadius: 12, margin: '2px 6px', cursor: 'pointer',
|
||||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? '#4f46e5' : '#475569',
|
||||
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: depth > 0 ? 14 : 16,
|
||||
flexShrink: 0,
|
||||
color: isActive ? '#6366f1' : '#64748b',
|
||||
}}>{menuIcon}</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render groups with children
|
||||
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(g => {
|
||||
const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
items.push(
|
||||
<div key={g.id}>
|
||||
<div style={{
|
||||
color: '#94a3b8', fontSize: 12, fontWeight: 600,
|
||||
padding: '12px 16px 6px', letterSpacing: 0.5, textTransform: 'uppercase',
|
||||
}}>
|
||||
{g.label}
|
||||
</div>
|
||||
{children.map(c => renderMenuItem(c, 1))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Render top-level pages
|
||||
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
|
||||
items.push(renderMenuItem(m));
|
||||
});
|
||||
|
||||
return items;
|
||||
})()}
|
||||
{topLevelItems.map(item => renderMenuItem(item))}
|
||||
</div>
|
||||
|
||||
{/* Recharge button - opens modal */}
|
||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||
margin: '8px 12px',
|
||||
padding: '12px 20px',
|
||||
@@ -541,7 +593,6 @@ const AppLayout: React.FC = () => {
|
||||
<span>充值积分</span>
|
||||
</div>
|
||||
|
||||
{/* User block at bottom-left */}
|
||||
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||
<div style={{
|
||||
@@ -589,14 +640,12 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="desktop-content" style={{
|
||||
marginLeft: sidebarW + 28,
|
||||
marginRight: 12,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
// height: '100%',
|
||||
minHeight: 'calc(100vh - 32px)',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
@@ -616,31 +665,154 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<div className="mobile-bottom-nav">
|
||||
{menuItems.filter((item) => (item.menu_type ?? item.menuType) !== 'group').map((item) => {
|
||||
if (!item.path) return null;
|
||||
const isActive = item.path === selectedKey;
|
||||
return (
|
||||
<div key={item.id}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.path)}>
|
||||
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
|
||||
<span>{item.label}</span>
|
||||
<div className="mobile-header">
|
||||
<div className="mobile-header-content">
|
||||
<div className="mobile-menu-btn" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined style={{ fontSize: 20 }} />
|
||||
</div>
|
||||
<div className="mobile-header-title">{siteName}</div>
|
||||
<div className="mobile-header-right">
|
||||
<div className="mobile-credits-badge" onClick={() => setRechargeModalOpen(true)}>
|
||||
<WalletOutlined />
|
||||
<span>{user?.credits || 0}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="nav-item" onClick={handleMobileRecharge}>
|
||||
<span className="nav-icon"><PlusCircleOutlined /></span>
|
||||
<span>充值</span>
|
||||
</div>
|
||||
<div className="nav-item" onClick={handleLogout}>
|
||||
<span className="nav-icon"><LogoutOutlined /></span>
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<div className="mobile-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
|
||||
}}>
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<ThunderboltOutlined style={{ fontSize: 18, color: '#ffffff' }} />
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: '#1e293b' }}>{siteName}</span>
|
||||
</div>
|
||||
}
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
closable={true}
|
||||
className="mobile-menu-drawer"
|
||||
styles={{
|
||||
header: { borderBottom: '1px solid #f1f5f9', padding: '16px 20px' },
|
||||
body: { padding: '12px 8px', display: 'flex', flexDirection: 'column' },
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 12 }}>
|
||||
{topLevelItems.map(item => {
|
||||
const menuType = item.menu_type ?? item.menuType;
|
||||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||||
const isActive = item.path === selectedKey;
|
||||
const isExpanded = mobileExpandedMenus[item.id];
|
||||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={`mobile-menu-item ${isActive && !hasChildren ? 'mobile-menu-item-active' : ''} ${menuType === 'group' ? 'mobile-menu-group' : ''}`}
|
||||
onClick={() => handleMobileMenuClick(item)}
|
||||
>
|
||||
{menuType !== 'group' && (
|
||||
<span className="mobile-menu-icon" style={{ color: isActive ? '#6366f1' : '#64748b' }}>
|
||||
{menuIcon}
|
||||
</span>
|
||||
)}
|
||||
<span className="mobile-menu-label" style={{
|
||||
paddingLeft: menuType === 'group' ? 0 : 0,
|
||||
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
|
||||
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
|
||||
fontSize: menuType === 'group' ? 12 : 15,
|
||||
textTransform: menuType === 'group' ? 'uppercase' : 'none',
|
||||
letterSpacing: menuType === 'group' ? 0.5 : 0,
|
||||
}}>
|
||||
{item.label}
|
||||
</span>
|
||||
{(menuType === 'group' || hasChildren) && (
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#cbd5e1',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}>
|
||||
<RightOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(menuType === 'group' || hasChildren) && isExpanded && (
|
||||
<div className="mobile-submenu">
|
||||
{(childMap[item.id] || []).map(child => {
|
||||
const childActive = child.path === selectedKey;
|
||||
const childIcon = iconMap[child.icon] || <HomeOutlined />;
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`mobile-menu-item mobile-submenu-item ${childActive ? 'mobile-menu-item-active' : ''}`}
|
||||
onClick={() => handleMobileMenuClick(child)}
|
||||
>
|
||||
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
|
||||
{childIcon}
|
||||
</span>
|
||||
<span className="mobile-menu-label" style={{
|
||||
color: childActive ? '#4f46e5' : '#64748b',
|
||||
fontWeight: childActive ? 600 : 400,
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{child.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid #f1f5f9' }}>
|
||||
<div
|
||||
className="mobile-menu-item mobile-recharge-item"
|
||||
onClick={() => {
|
||||
setRechargeModalOpen(true);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="mobile-menu-icon" style={{ color: '#fff' }}>
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}>充值积分</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="mobile-menu-icon" style={{ color: '#ef4444' }}>
|
||||
<LogoutOutlined />
|
||||
</span>
|
||||
<span className="mobile-menu-label" style={{ color: '#ef4444' }}>退出登录</span>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<Modal title={<Space><LockOutlined />修改密码</Space>} open={pwdModalOpen}
|
||||
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={440}>
|
||||
@@ -665,16 +837,31 @@ const AppLayout: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Recharge Modal */}
|
||||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
footer={null} width={680}>
|
||||
footer={null} width={680}
|
||||
className="recharge-modal"
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: '#ff0000ff', fontSize: 13 }}>
|
||||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请联系我们
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
@@ -698,7 +885,7 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 800, marginTop: 4,
|
||||
@@ -711,7 +898,6 @@ const AppLayout: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Payment method selection */}
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
@@ -757,10 +943,8 @@ const AppLayout: React.FC = () => {
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
// 检查是否有二维码信息,支持支付宝和微信支付
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
// Alipay or WeChat Pay: show the QR code
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
@@ -772,7 +956,6 @@ const AppLayout: React.FC = () => {
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
@@ -783,10 +966,8 @@ const AppLayout: React.FC = () => {
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
|
||||
// Start polling for payment status
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
// Mock mode (auto-completes, no QR needed)
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
@@ -809,12 +990,10 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* QR Code Payment Modal */}
|
||||
<Modal
|
||||
open={qrCodeModalOpen}
|
||||
onCancel={async () => {
|
||||
stopPolling();
|
||||
// Mark order as cancelled if it's still pending
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
|
||||
currentOrderNoRef.current = null;
|
||||
@@ -831,7 +1010,6 @@ const AppLayout: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
@@ -856,7 +1034,6 @@ const AppLayout: React.FC = () => {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
@@ -900,7 +1077,6 @@ const AppLayout: React.FC = () => {
|
||||
}}>
|
||||
购买 {currentPaymentInfo?.credits || 0} 积分
|
||||
</div>
|
||||
{/* 倒计时显示 */}
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: '8px 16px',
|
||||
@@ -920,7 +1096,6 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
|
||||
@@ -934,7 +1109,6 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<Button
|
||||
size="large"
|
||||
@@ -959,6 +1133,105 @@ const AppLayout: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<NotificationPopup />
|
||||
|
||||
<div className="contact-button-wrapper">
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div
|
||||
className="contact-tooltip"
|
||||
style={{
|
||||
opacity: contactHovered ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
联系我们
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setContactModalOpen(true)}
|
||||
className="contact-button"
|
||||
onMouseEnter={() => setContactHovered(true)}
|
||||
onMouseLeave={() => setContactHovered(false)}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 20 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={<Space><MessageOutlined />联系我们</Space>}
|
||||
open={contactModalOpen}
|
||||
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
||||
footer={null}
|
||||
width={480}
|
||||
className="contact-modal"
|
||||
>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Form form={contactForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="姓名"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input placeholder="请输入您的姓名" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入您的手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="companyName"
|
||||
label="公司名称"
|
||||
rules={[{ required: true, message: '请输入公司名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入公司名称" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="industry"
|
||||
label="您的行业"
|
||||
rules={[{ required: true, message: '请输入您的行业' }]}
|
||||
>
|
||||
<Input placeholder="请输入您的行业" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="message" label="留言(选填)">
|
||||
<Input.TextArea
|
||||
placeholder="请输入您的需求或问题"
|
||||
rows={3}
|
||||
style={{ borderRadius: 10 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
||||
style={{ borderRadius: 10, flex: 1 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleContactSubmit}
|
||||
loading={submittingContact}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
|
||||
|
||||
interface PreResultData {
|
||||
video_id: string; //视频id
|
||||
advertiser_id: number; //广告主id
|
||||
material_id: string; //素材id
|
||||
is_ad_high_quality_material: string; //是否优质素材
|
||||
is_ecp_high_quality_material: string; //是否千川优质素材
|
||||
is_inefficient_material: string; //是否低效素材
|
||||
is_first_publish_material: string; //是否首发素材
|
||||
not_ad_high_quality_reason: string[] | null; //AD非优质原因
|
||||
not_ecp_high_quality_reason: string[] | null; //千川非优质原因
|
||||
is_local_high_quality_material: string; //是否本地推优质素材
|
||||
}
|
||||
|
||||
interface PreResultDisplayProps {
|
||||
preResult: string;
|
||||
}
|
||||
|
||||
const qualityConfig: Record<string, { color: string; label: string }> = {
|
||||
YES: { color: 'green', label: '是' },
|
||||
NO: { color: 'red', label: '否' },
|
||||
UNKNOWN: { color: 'default', label: '未知' },
|
||||
};
|
||||
|
||||
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preResult) {
|
||||
setParsedData(null);
|
||||
setHasError(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(preResult);
|
||||
setParsedData(data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setParsedData(null);
|
||||
setHasError(true);
|
||||
}
|
||||
}, [preResult]);
|
||||
|
||||
if (!preResult || hasError || !parsedData) {
|
||||
return <span style={{ color: '#64748b' }}>-</span>;
|
||||
}
|
||||
|
||||
const allQualityFields = [
|
||||
parsedData.is_ad_high_quality_material,
|
||||
parsedData.is_ecp_high_quality_material,
|
||||
parsedData.is_local_high_quality_material,
|
||||
];
|
||||
|
||||
const hasNoQuality = allQualityFields.some((val) => val === 'NO');
|
||||
const allYes = allQualityFields.every((val) => val === 'YES');
|
||||
|
||||
let statusTag;
|
||||
if (hasNoQuality) {
|
||||
statusTag = <Tag color="red">非优质</Tag>;
|
||||
} else if (allYes) {
|
||||
statusTag = <Tag color="green">优质</Tag>;
|
||||
} else {
|
||||
statusTag = <Tag color="default">待评估</Tag>;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div style={{ maxWidth: 500 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>前测结果详情</Typography.Text>
|
||||
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="视频ID">{parsedData.video_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="广告主ID">{parsedData.advertiser_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="素材ID">{parsedData.material_id}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}>质量评估</Typography.Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||
<Tag color={qualityConfig[parsedData.is_ad_high_quality_material]?.color}>
|
||||
AD优质素材: {qualityConfig[parsedData.is_ad_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_ecp_high_quality_material]?.color}>
|
||||
千川优质素材: {qualityConfig[parsedData.is_ecp_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_local_high_quality_material]?.color}>
|
||||
本地推优质素材: {qualityConfig[parsedData.is_local_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_inefficient_material]?.color}>
|
||||
低效素材: {qualityConfig[parsedData.is_inefficient_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_first_publish_material]?.color}>
|
||||
首发素材: {qualityConfig[parsedData.is_first_publish_material]?.label}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
|
||||
AD非优质原因
|
||||
</Typography.Text>
|
||||
<List
|
||||
dataSource={parsedData.not_ad_high_quality_reason}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
|
||||
{index + 1}. {item}
|
||||
</List.Item>
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedData.not_ecp_high_quality_reason && parsedData.not_ecp_high_quality_reason.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
|
||||
千川非优质原因
|
||||
</Typography.Text>
|
||||
<List
|
||||
dataSource={parsedData.not_ecp_high_quality_reason}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
|
||||
{index + 1}. {item}
|
||||
</List.Item>
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} title={null} trigger="hover">
|
||||
{statusTag}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreResultDisplay;
|
||||
@@ -1,21 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography } from 'antd';
|
||||
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { getOAuthList, requestOAuth } from '../api';
|
||||
|
||||
const OPEN_TYPE_MAP: Record<number, string> = {
|
||||
1: '千川',
|
||||
2: '广告',
|
||||
3: '本地推',
|
||||
4: '星图',
|
||||
5: '快手代理商',
|
||||
6: '巨量星图',
|
||||
7: '巨量服务单',
|
||||
8: '腾讯服务单',
|
||||
9: '腾讯营销K2',
|
||||
10: '腾讯营销K3',
|
||||
};
|
||||
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
|
||||
|
||||
const PORT_TYPE_MAP: Record<number, string> = {
|
||||
1: '巨量',
|
||||
@@ -25,7 +11,6 @@ const PORT_TYPE_MAP: Record<number, string> = {
|
||||
5: '腾讯',
|
||||
};
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
@@ -38,6 +23,20 @@ const formatDateTime = (dateStr: string) => {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// 安全拼接URL,避免双斜杠
|
||||
const buildUrl = (path: string): string => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||
// 如果已经是完整URL(以http://或https://开头),直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 移除路径开头的斜杠(如果有)
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
// 移除baseUrl结尾的斜杠(如果有)
|
||||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
return `${cleanBase}/${cleanPath}`;
|
||||
};
|
||||
|
||||
interface AuthorizationData {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -62,11 +61,33 @@ const AuthorizationPage: React.FC = () => {
|
||||
open_type: undefined as number | undefined,
|
||||
account_id: '',
|
||||
});
|
||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
const [openTypeList, setOpenTypeList] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOAuthList();
|
||||
loadOpenTypeList();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
setOpenTypeList(data);
|
||||
} catch (error) {
|
||||
console.error('加载开户方式列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
@@ -121,6 +142,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
setSelectedOpenType(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
@@ -197,7 +219,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
title: '开户方式',
|
||||
dataIndex: 'openType',
|
||||
key: 'openType',
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{OPEN_TYPE_MAP[text] || text}</span>,
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台端口',
|
||||
@@ -225,16 +247,6 @@ const AuthorizationPage: React.FC = () => {
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: unknown, record: AuthorizationData) => (
|
||||
<Link to={`/consume?accountId=${record.id}`} style={{ color: '#6366f1' }}>
|
||||
查看消耗
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = authorizations.map((item, index) => ({
|
||||
@@ -264,10 +276,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={searchParams.open_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
|
||||
style={{ width: 140 }}
|
||||
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
|
||||
value: Number(key),
|
||||
label: value,
|
||||
}))}
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Input
|
||||
placeholder="账号ID"
|
||||
@@ -348,17 +357,43 @@ const AuthorizationPage: React.FC = () => {
|
||||
okText="确认授权"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
width={700}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择开户方式"
|
||||
value={selectedOpenType}
|
||||
onChange={(value) => setSelectedOpenType(value)}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
|
||||
value: Number(key),
|
||||
label: value,
|
||||
}))}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', maxHeight: 420, overflowY: 'auto' }}>
|
||||
{openTypeList.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedOpenType(item.openType)}
|
||||
style={{
|
||||
width: 'calc(33.33% - 12px)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${selectedOpenType === item.openType ? '#6366f1' : '#e2e8f0'}`,
|
||||
padding: 16,
|
||||
transition: 'all 0.3s ease',
|
||||
background: selectedOpenType === item.openType ? '#f0f1ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', height: 120, marginBottom: 12, borderRadius: 8, overflow: 'hidden' }}>
|
||||
{item.thumb ? (
|
||||
<img
|
||||
src={buildUrl(item.thumb)}
|
||||
alt={item.typeName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography.Text type="secondary">暂无图片</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>{item.typeName}</Typography.Text>
|
||||
<p style={{ fontSize: 12, color: '#64748b', marginTop: 8, marginBottom: 0, lineHeight: 1.5 }}>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Typography, Spin, Button, App } from 'antd';
|
||||
import { ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { juliang_callback, getOAuthList } from '../api';
|
||||
|
||||
const AuthorizationWaitingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [authSuccess, setAuthSuccess] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const authCode = searchParams.get('auth_code');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
if (!authCode || !state) {
|
||||
setIsChecking(false);
|
||||
setCountdown(10);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
auth_code: authCode,
|
||||
state: state,
|
||||
app_id: searchParams.get('app_id') || undefined,
|
||||
material_auth_status: searchParams.get('material_auth_status') || undefined,
|
||||
scope: searchParams.get('scope') || undefined,
|
||||
uid: searchParams.get('uid') || undefined,
|
||||
};
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
const res = await juliang_callback(params);
|
||||
setAuthSuccess(true);
|
||||
setIsChecking(false);
|
||||
message.success(res?.message || '授权成功');
|
||||
setTimeout(() => {
|
||||
navigate('/authorization');
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '授权失败';
|
||||
setAuthError(errorMsg);
|
||||
setIsChecking(false);
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev >= 11) {
|
||||
setIsChecking(false);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [navigate, searchParams, message]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setCountdown(0);
|
||||
setIsChecking(true);
|
||||
setAuthSuccess(false);
|
||||
setAuthError('');
|
||||
window.location.href = '/authorization';
|
||||
};
|
||||
|
||||
if (authSuccess) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
|
||||
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
|
||||
<div style={{ width: 80, height: 80, background: '#dcfce7', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
|
||||
<Typography.Text style={{ fontSize: 40, color: '#22c55e' }}>✓</Typography.Text>
|
||||
</div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16, color: '#1e293b' }}>授权成功</Typography.Title>
|
||||
<Typography.Text style={{ color: '#64748b' }}>正在跳转至授权管理页面...</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)' }}>
|
||||
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
|
||||
<div style={{ width: 80, height: 80, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
|
||||
{isChecking && countdown < 10 ? (
|
||||
<Spin size="large" tip="加载中" style={{ color: '#fff' }} />
|
||||
) : (
|
||||
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authError ? (
|
||||
<>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16, color: '#ef4444' }}>授权失败</Typography.Title>
|
||||
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}>{authError}</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRetry}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
fontWeight: 600,
|
||||
padding: '12px 32px',
|
||||
}}
|
||||
>
|
||||
重新授权
|
||||
</Button>
|
||||
</>
|
||||
) : countdown >= 10 ? (
|
||||
<>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16, color: '#ef4444' }}>授权无响应</Typography.Title>
|
||||
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}>请重新授权</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRetry}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
fontWeight: 600,
|
||||
padding: '12px 32px',
|
||||
}}
|
||||
>
|
||||
重新授权
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16, color: '#1e293b' }}>授权中,请等待</Typography.Title>
|
||||
<Typography.Text style={{ color: '#64748b', marginBottom: 24, display: 'block' }}>
|
||||
正在等待平台完成授权操作
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<ClockCircleOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#6366f1', fontWeight: 500, fontSize: 16 }}>
|
||||
{countdown}s
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorizationWaitingPage;
|
||||
@@ -41,12 +41,12 @@ const ConsumePage: React.FC = () => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async (page = 1, pageSizeNum = 10) => {
|
||||
const loadData = async (page = 1, pageSizeNum = 10, consumeDate?: [string, string] | null, advertiserIdParam?: string | null) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialConsumpList({
|
||||
advertiser_id: advertiserId || undefined,
|
||||
consume_date: consumeDateRange,
|
||||
advertiser_id: advertiserIdParam != null ? advertiserIdParam : advertiserId,
|
||||
consume_date: consumeDate != null ? consumeDate : consumeDateRange,
|
||||
page,
|
||||
page_size: pageSizeNum,
|
||||
});
|
||||
@@ -100,7 +100,7 @@ const ConsumePage: React.FC = () => {
|
||||
setAdvertiserId('');
|
||||
setConsumeDateRange(undefined);
|
||||
setCurrentPage(1);
|
||||
loadData(1, pageSize);
|
||||
loadData(1, pageSize, null, '');
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, message, Typography } from 'antd';
|
||||
import {
|
||||
StarOutlined,
|
||||
LockOutlined,
|
||||
GiftOutlined,
|
||||
ThunderboltOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const CreativePlazaPage: React.FC = () => {
|
||||
const handleUnlock = () => {
|
||||
message.info('请联系客服开通');
|
||||
};
|
||||
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <ThunderboltOutlined style={{ fontSize: 28, color: '#8b5cf6' }} />,
|
||||
title: 'AI智能生成',
|
||||
description: '先进的AI技术,一键生成高质量视频内容',
|
||||
},
|
||||
{
|
||||
icon: <PlayCircleOutlined style={{ fontSize: 28, color: '#ec4899' }} />,
|
||||
title: '多格式输出',
|
||||
description: '支持多种视频格式,满足不同场景需求',
|
||||
},
|
||||
{
|
||||
icon: <CheckCircleOutlined style={{ fontSize: 28, color: '#10b981' }} />,
|
||||
title: '品质保证',
|
||||
description: '专业级画质,细节清晰,色彩鲜艳',
|
||||
},
|
||||
{
|
||||
icon: <HeartOutlined style={{ fontSize: 28, color: '#f59e0b' }} />,
|
||||
title: '创意无限',
|
||||
description: '丰富的模板和风格,激发创作灵感',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 16px rgba(139,92,246,0.3)',
|
||||
}}>
|
||||
<GiftOutlined style={{ fontSize: 22, color: '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ margin: 0, color: '#1e293b', fontWeight: 700 }}>
|
||||
创意素材案例
|
||||
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 400, marginLeft: 8 }}>
|
||||
探索AI创作无限可能
|
||||
</span>
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text style={{ color: '#64748b' }}>
|
||||
精选AI生成的优秀素材案例,展示AI创作的无限潜力与创意灵感
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 20, marginBottom: 32 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, rgba(139,92,246,0.04) 0%, rgba(99,102,241,0.04) 100%)',
|
||||
border: '1px solid rgba(139,92,246,0.1)',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ marginBottom: 16 }}>{feature.icon}</div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 8, color: '#1e293b' }}>
|
||||
{feature.title}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||||
{feature.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#1e293b' }}>
|
||||
<StarOutlined style={{ marginRight: 8, color: '#f59e0b' }} />
|
||||
精选案例展示
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
onClick={handleUnlock}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<LockOutlined style={{ marginRight: 6 }} />
|
||||
解锁全部案例
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
background: '#fafafa',
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#94a3b8', fontSize: 14 }}>
|
||||
敬请期待更多创意案例
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreativePlazaPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,21 +39,21 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
// 上传配置弹窗相关状态
|
||||
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
|
||||
const [accountIdList, setAccountIdList] = useState<{
|
||||
const [accountIdLists, setAccountIdLists] = useState<{
|
||||
accountId: string;
|
||||
}[]>([]);
|
||||
const [accountIdInput, setAccountIdInput] = useState('');
|
||||
}[][]>([[]]);
|
||||
const [accountIdInputs, setAccountIdInputs] = useState<string[]>(['']);
|
||||
|
||||
const [oauthList, setOauthList] = useState<any[]>([]);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [oauthTotal, setOauthTotal] = useState(0);
|
||||
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
|
||||
const [selectedOauthItems, setSelectedOauthItems] = useState<({ value: string; label: string } | undefined)[]>([undefined]);
|
||||
const [materialFileNames, setMaterialFileNames] = useState<Map<string, string>>(new Map());
|
||||
const [unifiedFileName, setUnifiedFileName] = useState('');
|
||||
const updateFilenameDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [oauthPage, setOauthPage] = useState(1);
|
||||
const [oauthPageSize, setOauthPageSize] = useState(10);
|
||||
const [oauthSelectOpen, setOauthSelectOpen] = useState(false);
|
||||
const [oauthSelectOpens, setOauthSelectOpens] = useState<boolean[]>([false]);
|
||||
|
||||
// 上传任务历史弹窗相关状态
|
||||
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
|
||||
@@ -682,8 +682,21 @@ const GeneratedRecord: React.FC = () => {
|
||||
message.warning('请先选择要上传的媒体');
|
||||
return;
|
||||
}
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setAccountIdLists([[]]);
|
||||
setAccountIdInputs(['']);
|
||||
setSelectedOauthItems([undefined]);
|
||||
setUploadConfigModalVisible(true);
|
||||
};
|
||||
|
||||
const handleSinglePushToMedia = () => {
|
||||
if (!previewItem) return;
|
||||
const resourceId = getItemResourceId(previewItem);
|
||||
setSelectedItems(new Set([resourceId]));
|
||||
setAccountIdLists([[]]);
|
||||
setAccountIdInputs(['']);
|
||||
setSelectedOauthItems([undefined]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
setUploadConfigModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -743,7 +756,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
// 批量上传素材
|
||||
const handleStartBatchUpload = async () => {
|
||||
if (!selectedOauthItems) {
|
||||
const validOauthItems = selectedOauthItems.filter(item => item !== undefined);
|
||||
if (validOauthItems.length === 0) {
|
||||
message.warning('请先选择授权账户');
|
||||
return;
|
||||
}
|
||||
@@ -753,14 +767,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const tasks: {
|
||||
advertiser_ids: string[];
|
||||
resource_ids: string[];
|
||||
oauth_id: string;
|
||||
source_model: string;
|
||||
}[] = [];
|
||||
const advertiserIds = accountIdList.map(account => account.accountId);
|
||||
// 创建itemId到item对象的映射
|
||||
const itemMap = new Map<string, any>();
|
||||
recordlist.forEach((group: any) => {
|
||||
group.items.forEach((item: any) => {
|
||||
@@ -769,32 +775,64 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
});
|
||||
|
||||
for (const itemId of selectedItems) {
|
||||
const item = itemMap.get(itemId);
|
||||
// 根据item是否有generatedResourceId来决定source_model
|
||||
let sourceModel: string;
|
||||
if (item && hasGeneratedResourceId(item)) {
|
||||
sourceModel = 'generated_resources';
|
||||
} else {
|
||||
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
|
||||
const tasks: {
|
||||
advertiser_ids: string[];
|
||||
resource_ids: string[];
|
||||
oauth_id: string;
|
||||
source_model: string;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < validOauthItems.length; i++) {
|
||||
const oauthItem = validOauthItems[i];
|
||||
const advertiserIds = accountIdLists[i]?.map(account => account.accountId) || [];
|
||||
|
||||
if (advertiserIds.length === 0) {
|
||||
message.warning(`第 ${i + 1} 组授权账户未设置账户ID,已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
advertiser_ids: advertiserIds,
|
||||
resource_ids: [itemId],
|
||||
oauth_id: selectedOauthItems.value,
|
||||
source_model: sourceModel,
|
||||
const sourceModelMap = new Map<string, string[]>();
|
||||
|
||||
for (const itemId of selectedItems) {
|
||||
const item = itemMap.get(itemId);
|
||||
let sourceModel: string;
|
||||
if (item && hasGeneratedResourceId(item)) {
|
||||
sourceModel = 'generated_resources';
|
||||
} else {
|
||||
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
|
||||
}
|
||||
|
||||
if (!sourceModelMap.has(sourceModel)) {
|
||||
sourceModelMap.set(sourceModel, []);
|
||||
}
|
||||
sourceModelMap.get(sourceModel)!.push(itemId);
|
||||
}
|
||||
|
||||
sourceModelMap.forEach((resourceIds, sourceModel) => {
|
||||
tasks.push({
|
||||
advertiser_ids: advertiserIds,
|
||||
resource_ids: resourceIds,
|
||||
oauth_id: oauthItem.value,
|
||||
source_model: sourceModel,
|
||||
});
|
||||
});
|
||||
}
|
||||
await asyncBatchUploadMaterial({ tasks });
|
||||
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
|
||||
|
||||
const res = await asyncBatchUploadMaterial({ tasks });
|
||||
if (res.errors?.length > 0) {
|
||||
message.warning(res.message);
|
||||
} else if (res.code === 0) {
|
||||
message.success(res.message);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
// 关闭弹窗并清理状态
|
||||
setUploadConfigModalVisible(false);
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setAccountIdLists([[]]);
|
||||
setAccountIdInputs(['']);
|
||||
setSelectedOauthItems([undefined]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
} catch (error: any) {
|
||||
@@ -846,8 +884,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
// 批量更新 recordlist 中的文件名,使用 API 返回的 new_file_name
|
||||
const resultsMap = new Map<string, string>();
|
||||
response?.results?.forEach((r: any) => {
|
||||
if (r.success && r.new_file_name) {
|
||||
resultsMap.set(r.source_id, r.new_file_name);
|
||||
if (r.success && r.newFileName) {
|
||||
resultsMap.set(r.sourceId, r.newFileName);
|
||||
}
|
||||
});
|
||||
setRecordList(prevList => {
|
||||
@@ -864,7 +902,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
}),
|
||||
}));
|
||||
});
|
||||
const successCount = response?.success_count || 0;
|
||||
console.log(response);
|
||||
const successCount = response?.successCount || 0;
|
||||
message.success(`已更新 ${successCount} 个文件名`);
|
||||
} catch (error: any) {
|
||||
console.error('文件名更新失败:', error);
|
||||
@@ -1322,13 +1361,13 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
{/* 上传配置弹窗 */}
|
||||
<Modal
|
||||
title="批量上传配置"
|
||||
title={selectedItems.size === 1 ? '上传配置' : '批量上传配置'}
|
||||
open={uploadConfigModalVisible}
|
||||
onCancel={() => {
|
||||
setUploadConfigModalVisible(false);
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setAccountIdLists([[]]);
|
||||
setAccountIdInputs(['']);
|
||||
setSelectedOauthItems([undefined]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
}}
|
||||
@@ -1432,7 +1471,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
value={materialFileNames.get(itemId) || item?.fileName || ''}
|
||||
value={materialFileNames.has(itemId) ? materialFileNames.get(itemId)! : item?.fileName || ''}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
const newNames = new Map(materialFileNames);
|
||||
@@ -1456,147 +1495,196 @@ const GeneratedRecord: React.FC = () => {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||||
选择授权账户
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={selectedOauthItems}
|
||||
onChange={(value) => {
|
||||
setSelectedOauthItems(value as { value: string; label: string } | undefined);
|
||||
}}
|
||||
placeholder="点击选择授权账户"
|
||||
style={{ width: '100%', marginBottom: 16, borderRadius: 8 }}
|
||||
popupRender={() => (
|
||||
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
|
||||
<Table
|
||||
dataSource={oauthList}
|
||||
columns={[
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权应用ID',
|
||||
dataIndex: 'appid',
|
||||
key: 'appid',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权用户ID',
|
||||
dataIndex: 'accountUserid',
|
||||
key: 'accountUserid',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权账户角色',
|
||||
dataIndex: 'accountRole',
|
||||
key: 'accountRole',
|
||||
width: 200,
|
||||
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: '授权账户用户名',
|
||||
dataIndex: 'accountUsername',
|
||||
key: 'accountUsername',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
]}
|
||||
loading={oauthLoading}
|
||||
pagination={{
|
||||
current: oauthPage,
|
||||
pageSize: oauthPageSize,
|
||||
total: oauthTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
onChange: (page, size) => {
|
||||
setOauthPage(page);
|
||||
setOauthPageSize(size);
|
||||
loadOAuthList(page, size);
|
||||
},
|
||||
}}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
const id = String(record.id);
|
||||
setSelectedOauthItems({ value: id, label: String(record.accountUserid) });
|
||||
},
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedOauthItems?.value === String(record.id) ? '#e6f7ff' : undefined,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{selectedOauthItems.map((oauthItem, index) => (
|
||||
<div key={index} style={{ marginBottom: 16, padding: 12, border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
|
||||
授权账户 {index + 1}
|
||||
</Typography.Text>
|
||||
{selectedOauthItems.length > 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
onClick={() => {
|
||||
const newOauthItems = [...selectedOauthItems];
|
||||
const newAccountIdInputs = [...accountIdInputs];
|
||||
const newAccountIdLists = [...accountIdLists];
|
||||
const newOauthSelectOpens = [...oauthSelectOpens];
|
||||
newOauthItems.splice(index, 1);
|
||||
newAccountIdInputs.splice(index, 1);
|
||||
newAccountIdLists.splice(index, 1);
|
||||
newOauthSelectOpens.splice(index, 1);
|
||||
setSelectedOauthItems(newOauthItems);
|
||||
setAccountIdInputs(newAccountIdInputs);
|
||||
setAccountIdLists(newAccountIdLists);
|
||||
setOauthSelectOpens(newOauthSelectOpens);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
open={oauthSelectOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOauthSelectOpen(open);
|
||||
if (open) {
|
||||
loadOAuthList(1, oauthPageSize);
|
||||
}
|
||||
}}
|
||||
labelInValue
|
||||
fieldNames={{ label: 'accountUserid', value: 'id' }}
|
||||
/>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block', marginTop: 16 }}>
|
||||
粘贴账户ID(每行一个或用逗号分隔)
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={accountIdInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setAccountIdInput(value);
|
||||
const ids = value.split(/[\n,]/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
|
||||
const seen = new Set<string>();
|
||||
const finalAccounts = textAccounts.filter(a => {
|
||||
if (seen.has(a.accountId)) return false;
|
||||
seen.add(a.accountId);
|
||||
return true;
|
||||
});
|
||||
setAccountIdList(finalAccounts);
|
||||
}}
|
||||
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
|
||||
<Select
|
||||
value={oauthItem}
|
||||
onChange={(value) => {
|
||||
const newOauthItems = [...selectedOauthItems];
|
||||
newOauthItems[index] = value as { value: string; label: string } | undefined;
|
||||
setSelectedOauthItems(newOauthItems);
|
||||
}}
|
||||
placeholder="点击选择授权账户"
|
||||
style={{ width: '100%', marginBottom: 12, borderRadius: 8 }}
|
||||
popupRender={() => (
|
||||
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
|
||||
<Table
|
||||
dataSource={oauthList}
|
||||
columns={[
|
||||
{
|
||||
title: '授权账户ID',
|
||||
dataIndex: 'accountId',
|
||||
key: 'accountId',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '授权账户角色',
|
||||
dataIndex: 'accountRole',
|
||||
key: 'accountRole',
|
||||
width: 160,
|
||||
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: '授权账户用户名',
|
||||
dataIndex: 'accountUsername',
|
||||
key: 'accountUsername',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权用户ID',
|
||||
dataIndex: 'accountUserid',
|
||||
key: 'accountUserid',
|
||||
width: 120,
|
||||
},
|
||||
]}
|
||||
loading={oauthLoading}
|
||||
pagination={{
|
||||
current: oauthPage,
|
||||
pageSize: oauthPageSize,
|
||||
total: oauthTotal,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
onChange: (page, size) => {
|
||||
setOauthPage(page);
|
||||
setOauthPageSize(size);
|
||||
loadOAuthList(page, size);
|
||||
},
|
||||
}}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
const id = String(record.id);
|
||||
const newOauthItems = [...selectedOauthItems];
|
||||
newOauthItems[index] = { value: id, label: String(record.accountId)+'-'+(record.accountName || '-') };
|
||||
setSelectedOauthItems(newOauthItems);
|
||||
const newOauthSelectOpens = [...oauthSelectOpens];
|
||||
newOauthSelectOpens[index] = false;
|
||||
setOauthSelectOpens(newOauthSelectOpens);
|
||||
},
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
backgroundColor: oauthItem?.value === String(record.id) ? '#e6f7ff' : undefined,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
open={oauthSelectOpens[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newOauthSelectOpens = [...oauthSelectOpens];
|
||||
newOauthSelectOpens[index] = open;
|
||||
setOauthSelectOpens(newOauthSelectOpens);
|
||||
if (open) {
|
||||
loadOAuthList(1, oauthPageSize);
|
||||
}
|
||||
}}
|
||||
labelInValue
|
||||
fieldNames={{ label: 'accountUserid', value: 'id' }}
|
||||
/>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||||
粘贴账户ID(每行一个或用逗号分隔)
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={accountIdInputs[index]}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const newAccountIdInputs = [...accountIdInputs];
|
||||
newAccountIdInputs[index] = value;
|
||||
setAccountIdInputs(newAccountIdInputs);
|
||||
const ids = value.split(/[\n,]/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
|
||||
const seen = new Set<string>();
|
||||
const finalAccounts = textAccounts.filter(a => {
|
||||
if (seen.has(a.accountId)) return false;
|
||||
seen.add(a.accountId);
|
||||
return true;
|
||||
});
|
||||
const newAccountIdLists = [...accountIdLists];
|
||||
newAccountIdLists[index] = finalAccounts;
|
||||
setAccountIdLists(newAccountIdLists);
|
||||
}}
|
||||
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
|
||||
10001,10002,10003
|
||||
10004"
|
||||
rows={4}
|
||||
rows={3}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
onClick={() => {
|
||||
setSelectedOauthItems([...selectedOauthItems, undefined]);
|
||||
setAccountIdInputs([...accountIdInputs, '']);
|
||||
setAccountIdLists([...accountIdLists, []]);
|
||||
setOauthSelectOpens([...oauthSelectOpens, false]);
|
||||
}}
|
||||
style={{ borderRadius: 8, marginBottom: 16 }}
|
||||
/>
|
||||
>
|
||||
+ 新增授权账户组
|
||||
</Button>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{
|
||||
@@ -1607,9 +1695,9 @@ const GeneratedRecord: React.FC = () => {
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUploadConfigModalVisible(false);
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setAccountIdLists([[]]);
|
||||
setAccountIdInputs(['']);
|
||||
setSelectedOauthItems([undefined]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
}}
|
||||
@@ -1621,7 +1709,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
type="primary"
|
||||
onClick={handleStartBatchUpload}
|
||||
loading={uploading}
|
||||
disabled={uploading || accountIdList.length === 0}
|
||||
disabled={uploading || accountIdLists.every(list => list.length === 0)}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{uploading ? '上传中...' : '开始上传'}
|
||||
@@ -1677,25 +1765,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
key: 'advertiserId',
|
||||
width: 180,
|
||||
},
|
||||
// {
|
||||
// title: '状态',
|
||||
// dataIndex: 'status',
|
||||
// key: 'status',
|
||||
// width: 100,
|
||||
// render: (status: number, record: any) => {
|
||||
// const statusColorMap: Record<number, string> = {
|
||||
// 1: '#f59e0b',
|
||||
// 2: '#6366f1',
|
||||
// 3: '#10b981',
|
||||
// 4: '#ef4444',
|
||||
// };
|
||||
// return (
|
||||
// <Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
|
||||
// {record.status_text || status}
|
||||
// </Tag>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -1725,7 +1794,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 250,
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (note: string) => (
|
||||
<span style={{ color: '#94a3b8' }}>
|
||||
@@ -1735,10 +1804,17 @@ const GeneratedRecord: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => formatDateTime(text),
|
||||
},
|
||||
|
||||
]}
|
||||
@@ -2069,15 +2145,16 @@ const GeneratedRecord: React.FC = () => {
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
{/* <div>
|
||||
<div>
|
||||
<Button
|
||||
|
||||
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
|
||||
type="primary"
|
||||
onClick={handleSinglePushToMedia}
|
||||
style={{ width: '100%', borderRadius: 8, marginTop: 20 }}
|
||||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||||
>
|
||||
|
||||
推送媒体后台
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-image: url(/backimage.png);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-page {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.login-bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-decoration {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-decoration-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
|
||||
top: -150px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.login-decoration-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
|
||||
bottom: -100px;
|
||||
left: -80px;
|
||||
filter: blur(50px);
|
||||
}
|
||||
|
||||
.login-left-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-left-section {
|
||||
padding: 0 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-left-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-logo-row {
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo-img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
objectFit: contain;
|
||||
}
|
||||
|
||||
.login-logo-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
|
||||
}
|
||||
|
||||
.login-site-name {
|
||||
color: #1e293b;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-site-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
color: #64748b !important;
|
||||
font-size: 17px !important;
|
||||
max-width: 480px;
|
||||
line-height: 1.8 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.login-desc {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-features-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-features-list {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.login-feature-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 18px 22px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-feature-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6366f1;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-feature-title {
|
||||
color: #1e293b !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 600 !important;
|
||||
display: block !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.login-feature-desc {
|
||||
color: #64748b !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
padding: 16px 16px 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.login-right-section {
|
||||
padding: 40px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
border-radius: 20px !important;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
.login-card .ant-card-body {
|
||||
padding: 24px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card-title {
|
||||
text-align: center !important;
|
||||
margin-bottom: 6px !important;
|
||||
color: #1e293b !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.login-card-subtitle {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 24px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-tab-active {
|
||||
font-weight: 600 !important;
|
||||
color: #6366f1 !important;
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(99,102,241,0.2) !important;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.1) !important;
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 8px 24px rgba(99,102,241,0.25) !important;
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 0 10px 10px 0 !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
border-left: none !important;
|
||||
font-weight: 600 !important;
|
||||
min-width: 100px !important;
|
||||
}
|
||||
|
||||
.login-agreement {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-agreement-text {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #6366f1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.login-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.login-switch-btn {
|
||||
font-size: 13px !important;
|
||||
color: #6366f1 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-page .ant-input-affix-wrapper {
|
||||
padding: 0 11px !important;
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
.login-page .ant-input-affix-wrapper .ant-input-prefix {
|
||||
margin-right: 10px !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.login-page .ant-input {
|
||||
padding-left: 11px !important;
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-left-section {
|
||||
padding: 28px 16px 12px;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
padding: 12px 16px 32px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-left-section {
|
||||
padding: 24px 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-right-section {
|
||||
padding: 8px 12px 24px;
|
||||
}
|
||||
|
||||
.shiny-text-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-page .ant-input,
|
||||
.login-page .ant-input-affix-wrapper {
|
||||
height: 44px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.login-page .ant-input-affix-wrapper .ant-input {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.login-code-btn {
|
||||
height: 44px !important;
|
||||
min-width: 90px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 44px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
|
||||
import './LoginPage.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||
|
||||
@@ -73,7 +74,6 @@ const LoginPage: React.FC = () => {
|
||||
if (!checkAgreed()) return;
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
// setLoading(true);
|
||||
await login(values.phone, values.password, undefined, values.rememberMe);
|
||||
message.success('登录成功,欢迎回来');
|
||||
await checkAuth();
|
||||
@@ -82,7 +82,6 @@ const LoginPage: React.FC = () => {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
} finally {
|
||||
// setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,7 +130,6 @@ const LoginPage: React.FC = () => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
setShowSliderVerify(false);
|
||||
// 倒计时结束后,设置重新发送状态
|
||||
if (isReg) {
|
||||
setShowResend(true);
|
||||
} else {
|
||||
@@ -144,7 +142,6 @@ const LoginPage: React.FC = () => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 登录模式倒计时结束后重置验证状态
|
||||
useEffect(() => {
|
||||
if (countdown === 0 && mode === 'phone') {
|
||||
setLoginSliderVerified(false);
|
||||
@@ -158,14 +155,12 @@ const LoginPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册时需要滑动验证
|
||||
if (isReg) {
|
||||
setShowSliderVerify(true);
|
||||
setSliderVerified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 登录时也需要滑动验证
|
||||
if (!loginSliderVerified) {
|
||||
setShowSliderVerify(true);
|
||||
setLoginSliderVerified(false);
|
||||
@@ -185,7 +180,6 @@ const LoginPage: React.FC = () => {
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
// 发送失败,重置验证状态、倒计时和滑块组件
|
||||
if (mode === 'register') {
|
||||
setSliderVerified(false);
|
||||
setRegCountdown(0);
|
||||
@@ -193,18 +187,13 @@ const LoginPage: React.FC = () => {
|
||||
setLoginSliderVerified(false);
|
||||
setCountdown(0);
|
||||
}
|
||||
// 通过更新 key 强制刷新滑块组件
|
||||
setSliderKey(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 滑动验证成功后的回调
|
||||
const handleSliderSuccess = async (isResend = false) => {
|
||||
// 根据当前模式获取手机号
|
||||
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
|
||||
try {
|
||||
|
||||
|
||||
let mode2 = '';
|
||||
if (mode === 'register') {
|
||||
mode2 = 'register';
|
||||
@@ -213,7 +202,6 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
await sendSms(phone,mode2);
|
||||
// 设置对应的验证状态
|
||||
if (mode === 'register') {
|
||||
setSliderVerified(true);
|
||||
startCountdown(setRegCountdown, true);
|
||||
@@ -225,7 +213,6 @@ const LoginPage: React.FC = () => {
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
// 发送失败,重置验证状态、倒计时和滑块组件
|
||||
if (mode === 'register') {
|
||||
setSliderVerified(false);
|
||||
setRegCountdown(0);
|
||||
@@ -235,7 +222,6 @@ const LoginPage: React.FC = () => {
|
||||
setCountdown(0);
|
||||
setLoginShowResend(true);
|
||||
}
|
||||
// 通过更新 key 强制刷新滑块组件
|
||||
setSliderKey(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
@@ -250,9 +236,7 @@ const LoginPage: React.FC = () => {
|
||||
setShowSliderVerify(false);
|
||||
setShowResend(false);
|
||||
setLoginShowResend(false);
|
||||
// 刷新滑动验证组件
|
||||
setSliderKey(prev => prev + 1);
|
||||
// 清空当前模式相关的表单
|
||||
if (t === 'password') {
|
||||
pwdForm.resetFields();
|
||||
} else {
|
||||
@@ -270,7 +254,6 @@ const LoginPage: React.FC = () => {
|
||||
background: '#fff',
|
||||
border: '1.5px solid #e2e8f0',
|
||||
color: '#1e293b',
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
fontSize: 14,
|
||||
};
|
||||
@@ -280,93 +263,45 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
backgroundImage: `url(/backimage.png)`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundAttachment: 'fixed',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
width: 500, height: 500, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
|
||||
top: -150, right: -100, filter: 'blur(40px)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
width: 400, height: 400, borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
|
||||
bottom: -100, left: -80, filter: 'blur(50px)',
|
||||
}} />
|
||||
<div className="login-page">
|
||||
<div className="login-bg-overlay" />
|
||||
<div className="login-decoration login-decoration-1" />
|
||||
<div className="login-decoration login-decoration-2" />
|
||||
|
||||
{/* Left side - features */}
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'center', padding: '0 80px', zIndex: 1,
|
||||
}}>
|
||||
<Space direction="vertical" size={36}>
|
||||
<div className="login-left-section">
|
||||
<Space direction="vertical" size={36} className="login-left-content">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<div className="login-logo-row">
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
|
||||
<img src={siteLogo} alt="logo" className="login-logo-img" />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
||||
}}>
|
||||
<div className="login-logo-placeholder">
|
||||
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
|
||||
{siteName}
|
||||
</span>
|
||||
<span className="login-site-name">{siteName}</span>
|
||||
</div>
|
||||
<div className="shiny-text-container">
|
||||
<span className="shiny-text">AI赋能创意,素材触手可及</span>
|
||||
</div>
|
||||
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
|
||||
<Typography.Paragraph className="login-desc">
|
||||
专业的 AI 素材生成平台,通过智能提示词优化,<br />让您的创意快速转化为精美视频、图片
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div className="login-features-list">
|
||||
{features.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="electric-border-card"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex', gap: 16, alignItems: 'flex-start',
|
||||
padding: '18px 22px', borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className="electric-border-card login-feature-card"
|
||||
>
|
||||
<div className="electric-border" />
|
||||
<div className="electric-border-inner" />
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#6366f1', fontSize: 20,
|
||||
}}>{f.icon}</div>
|
||||
<div className="login-feature-icon">{f.icon}</div>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
|
||||
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
|
||||
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -374,48 +309,26 @@ const LoginPage: React.FC = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Right side - login/register form */}
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
|
||||
}}>
|
||||
<Card style={{
|
||||
width: 440, maxWidth: '100%', borderRadius: 20,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e2e8f0', background: '#fff',
|
||||
}} styles={{ body: { padding: '36px 28px' } }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
|
||||
<div className="login-right-section">
|
||||
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
|
||||
<Typography.Title level={3} className="login-card-title">
|
||||
{mode === 'register' ? '创建账号' : '欢迎回来'}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
|
||||
<Typography.Text className="login-card-subtitle">
|
||||
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
|
||||
</Typography.Text>
|
||||
|
||||
{/* Tab switcher - only for login modes */}
|
||||
{mode !== 'register' && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 0, marginBottom: 24,
|
||||
background: '#f1f5f9', borderRadius: 10, padding: 4,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<div className="login-tabs">
|
||||
{(['password', 'phone'] as const).map((t) => (
|
||||
<div key={t} onClick={() => switchTab(t)} style={{
|
||||
flex: 1, textAlign: 'center', padding: '10px 0',
|
||||
borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
||||
fontWeight: tab === t ? 600 : 400,
|
||||
color: tab === t ? '#6366f1' : '#64748b',
|
||||
background: tab === t ? '#fff' : 'transparent',
|
||||
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
||||
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
<div key={t} onClick={() => switchTab(t)}
|
||||
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
|
||||
{t === 'password' ? '密码登录' : '验证码登录'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Login */}
|
||||
{mode === 'password' && (
|
||||
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
@@ -428,16 +341,11 @@ const LoginPage: React.FC = () => {
|
||||
<Checkbox>记住我的登录状态</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block style={{
|
||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
||||
}}>登 录</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn">登 录</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Phone Login */}
|
||||
{mode === 'phone' && (
|
||||
<Form form={phoneForm} size="large" layout="vertical">
|
||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
@@ -454,27 +362,18 @@ const LoginPage: React.FC = () => {
|
||||
<Button disabled={countdown > 0}
|
||||
onClick={() => {
|
||||
if (loginShowResend) {
|
||||
// 重新发送时,只刷新滑块,不启动倒计时
|
||||
setLoginSliderVerified(false);
|
||||
setShowSliderVerify(true);
|
||||
setSliderKey(prev => prev + 1);
|
||||
} else {
|
||||
// 首次点击,调用发送验证码,等待滑块验证
|
||||
handleSendCode(phoneForm.getFieldValue('phone'));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
height: 48, borderRadius: '0 10px 10px 0',
|
||||
background: countdown > 0 ? '#f1f5f9' : (loginSliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
|
||||
border: '1.5px solid #e2e8f0', borderLeft: 'none',
|
||||
color: countdown > 0 ? '#94a3b8' : '#6366f1',
|
||||
fontWeight: 600, minWidth: 100,
|
||||
}}>
|
||||
className="login-code-btn">
|
||||
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
{/* 滑动验证 - 获取验证码后显示 */}
|
||||
{showSliderVerify && (
|
||||
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
||||
<SliderVerify
|
||||
@@ -485,16 +384,11 @@ const LoginPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block style={{
|
||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
||||
}}>登 录</Button>
|
||||
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn">登 录</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Register */}
|
||||
{mode === 'register' && (
|
||||
<Form form={regForm} size="large" layout="vertical">
|
||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
@@ -511,27 +405,18 @@ const LoginPage: React.FC = () => {
|
||||
<Button disabled={regCountdown > 0}
|
||||
onClick={() => {
|
||||
if (showResend) {
|
||||
// 重新发送时,只刷新滑块,不启动倒计时
|
||||
setSliderVerified(false);
|
||||
setShowSliderVerify(true);
|
||||
setSliderKey(prev => prev + 1);
|
||||
} else {
|
||||
// 首次点击,调用发送验证码,等待滑块验证
|
||||
handleSendCode(regForm.getFieldValue('phone'), true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
height: 48, borderRadius: '0 10px 10px 0',
|
||||
background: regCountdown > 0 ? '#f1f5f9' : (sliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
|
||||
border: '1.5px solid #e2e8f0', borderLeft: 'none',
|
||||
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
|
||||
fontWeight: 600, minWidth: 100,
|
||||
}}>
|
||||
className="login-code-btn">
|
||||
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
{/* 滑动验证 - 获取验证码后显示 */}
|
||||
{showSliderVerify && (
|
||||
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
||||
<SliderVerify
|
||||
@@ -544,41 +429,33 @@ const LoginPage: React.FC = () => {
|
||||
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
<Form.Item style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" onClick={handleRegister} loading={loading} block style={{
|
||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
||||
}}>注 册</Button>
|
||||
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn">注 册</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Agreement checkbox */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="login-agreement">
|
||||
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
|
||||
<span style={{ fontSize: 13, color: '#64748b' }}>
|
||||
<span className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
|
||||
style={{ color: '#6366f1', cursor: 'pointer' }}
|
||||
className="login-link"
|
||||
>《用户协议》</span>
|
||||
和
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
|
||||
style={{ color: '#6366f1', cursor: 'pointer' }}
|
||||
className="login-link"
|
||||
>《隐私政策》</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
{/* Bottom left: switch between login and register */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div className="login-footer">
|
||||
{mode === 'register' ? (
|
||||
<Typography.Text
|
||||
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('password');
|
||||
setTab('password');
|
||||
@@ -594,7 +471,7 @@ const LoginPage: React.FC = () => {
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
|
||||
className="login-switch-btn"
|
||||
onClick={() => {
|
||||
setMode('register');
|
||||
setShowResend(false);
|
||||
@@ -614,7 +491,6 @@ const LoginPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 滑动验证组件
|
||||
const SliderVerify: React.FC<{
|
||||
onSuccess: () => void;
|
||||
isVerified: boolean;
|
||||
@@ -623,13 +499,12 @@ const SliderVerify: React.FC<{
|
||||
const sliderRef = React.useRef<HTMLDivElement>(null);
|
||||
const trackRef = React.useRef<HTMLDivElement>(null);
|
||||
const positionRef = React.useRef(0);
|
||||
const successRef = React.useRef(false); // 防止重复触发成功回调
|
||||
const successRef = React.useRef(false);
|
||||
|
||||
const [containerWidth, setContainerWidth] = React.useState(360); // 容器宽度,自适应
|
||||
const sliderWidth = 50; // 滑块宽度
|
||||
const [containerWidth, setContainerWidth] = React.useState(360);
|
||||
const sliderWidth = 50;
|
||||
const maxPosition = containerWidth - sliderWidth;
|
||||
|
||||
// 监听容器尺寸变化,使其自适应父容器宽度
|
||||
React.useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -656,13 +531,11 @@ const SliderVerify: React.FC<{
|
||||
}, []);
|
||||
|
||||
const updatePosition = (x: number) => {
|
||||
// 如果已经成功,不再处理
|
||||
if (successRef.current || isVerified) return;
|
||||
|
||||
const newPosition = Math.max(0, Math.min(x, maxPosition));
|
||||
positionRef.current = newPosition;
|
||||
|
||||
// 直接操作DOM,避免React状态更新的开销
|
||||
if (sliderRef.current) {
|
||||
sliderRef.current.style.left = `${newPosition}px`;
|
||||
}
|
||||
@@ -670,12 +543,9 @@ const SliderVerify: React.FC<{
|
||||
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
|
||||
}
|
||||
|
||||
// 验证成功
|
||||
if (newPosition >= maxPosition - 5) {
|
||||
// 设置成功标志,防止重复触发
|
||||
successRef.current = true;
|
||||
|
||||
// 设置验证成功状态
|
||||
if (sliderRef.current) {
|
||||
sliderRef.current.style.background = '#22c55e';
|
||||
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||
@@ -685,7 +555,6 @@ const SliderVerify: React.FC<{
|
||||
trackRef.current.style.background = '#dcfce7';
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
@@ -708,7 +577,6 @@ const SliderVerify: React.FC<{
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// 如果没有滑到终点,重置位置
|
||||
if (!successRef.current && positionRef.current < maxPosition - 5) {
|
||||
positionRef.current = 0;
|
||||
if (sliderRef.current) {
|
||||
@@ -723,11 +591,49 @@ const SliderVerify: React.FC<{
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (successRef.current || isVerified) return;
|
||||
e.preventDefault();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
const startX = touch.clientX - rect.left - sliderWidth / 2;
|
||||
updatePosition(startX);
|
||||
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
const moveTouch = moveEvent.touches[0];
|
||||
const x = moveTouch.clientX - rect.left - sliderWidth / 2;
|
||||
updatePosition(x);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
|
||||
if (!successRef.current && positionRef.current < maxPosition - 5) {
|
||||
positionRef.current = 0;
|
||||
if (sliderRef.current) {
|
||||
sliderRef.current.style.left = '0px';
|
||||
}
|
||||
if (trackRef.current) {
|
||||
trackRef.current.style.width = `${sliderWidth}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 50,
|
||||
@@ -742,7 +648,6 @@ const SliderVerify: React.FC<{
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{/* 已滑动部分背景 - 包含阴影弧度 */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{
|
||||
@@ -762,7 +667,6 @@ const SliderVerify: React.FC<{
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 文字提示 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -793,7 +697,6 @@ const SliderVerify: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滑块 */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
@@ -33,10 +35,9 @@ const resourceTypeConfig: Record<string, { label: string; color: string }> = {
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
'1': { label: '待上传', color: 'orange' },
|
||||
'2': { label: '上传中', color: 'processing' },
|
||||
'3': { label: '上传成功', color: 'success' },
|
||||
'4': { label: '上传失败', color: 'error' },
|
||||
'FAILED': { label: '失败', color: 'error' },
|
||||
'PENDING': { label: '处理中', color: 'processing' },
|
||||
'SUCCESS': { label: '成功', color: 'success' },
|
||||
};
|
||||
|
||||
interface MaterialResource {
|
||||
@@ -231,18 +232,17 @@ const MaterialListPage: React.FC = () => {
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
// render: (text: string) => {
|
||||
// const config = statusConfig[text];
|
||||
// return <Tag color={config?.color}>{config?.label || text}</Tag>;
|
||||
// },
|
||||
render: (text: string) => {
|
||||
const config = statusConfig[text];
|
||||
return <Tag color={config?.color}>{config?.label || text || '-'}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '前测结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
render: (text: string) => <PreResultDisplay preResult={text} />,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
@@ -267,6 +267,16 @@ const MaterialListPage: React.FC = () => {
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: unknown, record) => (
|
||||
<Link to={`/consume?accountId=${record.advertiserId}`} style={{ color: '#6366f1' }}>
|
||||
查看消耗
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// title: '更新时间',
|
||||
// dataIndex: 'updatedAt',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, message, Typography } from 'antd';
|
||||
import {
|
||||
StarOutlined,
|
||||
CrownOutlined,
|
||||
LockOutlined,
|
||||
ThunderboltOutlined,
|
||||
GiftOutlined,
|
||||
BarChartOutlined,
|
||||
FireOutlined,
|
||||
TrophyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const PopularPage: React.FC = () => {
|
||||
const handleUnlock = () => {
|
||||
message.info('请联系客服开通');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 16px rgba(245,158,11,0.3)',
|
||||
}}>
|
||||
<BarChartOutlined style={{ fontSize: 22, color: '#fff' }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ margin: 0, color: '#1e293b', fontWeight: 700 }}>
|
||||
行业爆款大盘
|
||||
<span style={{ color: '#6366f1', fontSize: 14, fontWeight: 400, marginLeft: 8 }}>
|
||||
发现热门素材趋势
|
||||
</span>
|
||||
</Typography.Title>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text style={{ color: '#64748b' }}>
|
||||
实时追踪各行业爆款素材,把握创作风向,打造热门内容
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
background: 'linear-gradient(135deg, rgba(245,158,11,0.04) 0%, rgba(217,119,6,0.04) 100%)',
|
||||
border: '1px solid rgba(245,158,11,0.1)',
|
||||
padding: 10,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBottom: 20, color: '#1e293b' }}>
|
||||
<FireOutlined style={{ marginRight: 8, color: '#f59e0b' }} />
|
||||
行业热度排行
|
||||
</Typography.Title>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
background: '#fafafa',
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#94a3b8', fontSize: 14 }}>
|
||||
敬请期待行业热度排行
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0, color: '#1e293b' }}>
|
||||
<TrophyOutlined style={{ marginRight: 8, color: '#6366f1' }} />
|
||||
爆款素材榜单
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
onClick={handleUnlock}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<LockOutlined style={{ marginRight: 6 }} />
|
||||
立即解锁全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
background: '#fafafa',
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: '#94a3b8', fontSize: 14 }}>
|
||||
敬请期待更多爆款素材
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularPage;
|
||||
@@ -436,7 +436,7 @@ const PreTest: React.FC = () => {
|
||||
<div style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>前测管理</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>素材前测</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginBottom: 16 }}>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user