This commit is contained in:
2026-06-24 11:54:48 +08:00
20 changed files with 1645 additions and 833 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
#VITE_API_BASE=http://localhost:8000
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_API_BASE=http://192.168.120.17:8000
# VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
+7
View File
@@ -3,8 +3,11 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AdminLayout from './pages/AdminLayout';
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 AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
@@ -28,6 +31,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -96,6 +100,9 @@ const App = () => {
<Route path="shot-replications" element={<AdminShotReplications />} />
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
<Route path="authoriza" element={<AdminAuthoriz />} />
<Route path="consume" element={<AdminConsume />} />
<Route path="platform" element={<AdminPlatform />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+26
View File
@@ -540,3 +540,29 @@ export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaCo
export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise<VideoPromptSchemaPreviewOut> {
return api.post<VideoPromptSchemaPreviewOut>('/admin/video-prompt-schema-config/preview', payload);
}
// 获取授权链接
export interface RequestOAuthParams {
open_type: number;
}
export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
return api.post(`/user-oauth/request_oauth`, params);
}
// 授权列表
export interface OAuthListParams {
account_userid?: string;
open_type?: number;
account_id?: string;
page?: number;
page_size?: number;
}
export async function getOAuthList(params: OAuthListParams): Promise<any> {
const query = new URLSearchParams();
if (params.account_userid) query.set('account_userid', params.account_userid);
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
if (params.account_id) query.set('account_id', params.account_id);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/user-oauth/oauth_list?${query.toString()}`);
}
+362
View File
@@ -0,0 +1,362 @@
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',
};
const PORT_TYPE_MAP: Record<number, string> = {
1: '巨量',
2: '磁力',
3: '巨量星图',
4: '服务单',
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);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface AuthorizationData {
id: string;
status: string;
description: string;
account_userid?: string;
open_type?: number;
account_id?: string;
}
const AuthorizationPage: React.FC = () => {
const { message } = App.useApp();
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [selectedOpenType, setSelectedOpenType] = useState<number | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchParams, setSearchParams] = useState({
account_userid: '',
open_type: undefined as number | undefined,
account_id: '',
});
useEffect(() => {
loadOAuthList();
}, []);
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
setListLoading(true);
try {
const response = await getOAuthList({
page,
page_size: pageSize,
account_userid: params.account_userid || undefined,
open_type: params.open_type,
account_id: params.account_id || undefined,
});
if (response) {
if (response.data) {
setAuthorizations(response.data.data || response.data);
}
if (response.pagination) {
setTotal(response.pagination.total || 0);
setCurrentPage(response.pagination.page || 1);
setPageSize(response.pagination.pageSize || 10);
}
} else {
setAuthorizations(response || []);
}
} catch (error) {
message.error('获取授权列表失败');
} finally {
setListLoading(false);
}
};
const handleAuthorize = () => {
setShowModal(true);
};
const handleConfirm = async () => {
if (!selectedOpenType) {
message.warning('请选择开户方式');
return;
}
setLoading(true);
try {
const response = await requestOAuth({ open_type: selectedOpenType });
if (response.authUrl) {
window.open(response.authUrl, '_blank');
} else {
message.error('获取授权链接失败');
}
} catch (error) {
message.error('请求授权失败');
} finally {
setLoading(false);
setShowModal(false);
setSelectedOpenType(undefined);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 160,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权账户用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
{
title: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
},
{
title: '是否敏感物料授权',
dataIndex: 'materialAuthStatus',
key: 'materialAuthStatus',
width: 100,
render: (text: boolean) => (
<Tag color={text ? 'green' : 'default'}>
{text ? '是' : '否'}
</Tag>
),
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
render: (text: number) => <span style={{ color: '#1e293b' }}>{OPEN_TYPE_MAP[text] || text}</span>,
},
{
title: '平台端口',
dataIndex: 'portType',
key: 'portType',
ellipsis: true,
render: (text: number) => <span style={{ color: '#1e293b' }}>{PORT_TYPE_MAP[text] || text}</span>,
},
{
title: '用户id',
dataIndex: 'userId',
key: 'userId',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
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) => ({
...item,
index: index + 1,
key: item.id,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<LockOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button
type="primary"
size="medium"
icon={<PlusOutlined />}
onClick={handleAuthorize}
loading={loading}
style={{
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
}}
>
</Button>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="账号用户ID"
value={searchParams.account_userid}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_userid: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Select
placeholder="开户方式"
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,
}))}
/>
<Input
placeholder="账号ID"
value={searchParams.account_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_id: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Button
type="primary"
size="medium"
onClick={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
>
</Button>
<Button
size="medium"
onClick={() => {
setSearchParams({ account_userid: '', open_type: undefined, account_id: '' });
setCurrentPage(1);
loadOAuthList(1, pageSize);
}}
>
</Button>
</div>
<Table
dataSource={tableData}
columns={columns}
loading={listLoading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize: pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
loadOAuthList(page, size);
},
size: 'small',
}}
/>
</Card>
<Modal
title="选择开户方式"
open={showModal}
onOk={handleConfirm}
onCancel={() => {
setShowModal(false);
setSelectedOpenType(undefined);
}}
okText="确认授权"
cancelText="取消"
confirmLoading={loading}
>
<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,
}))}
/>
</Modal>
</div>
);
};
export default AuthorizationPage;
+267
View File
@@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import { Card, Space, Table, Button, Typography, Modal, Checkbox, Input, message } from 'antd';
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
// 表头字段模拟数据
const mockColumnsData = [
{ name: 'id', description: 'id' },
{ name: 'star_id', description: '星图ID' },
{ name: 'demand_id', description: '任务ID' },
{ name: 'demand_name', description: '任务名称' },
{ name: 'demander_name', description: '客户名称' },
{ name: 'brand', description: '品牌' },
{ name: 'max_publish_count', description: '最大发布数量' },
{ name: 'product_name', description: '产品名称' },
{ name: 'product_information', description: '产品信息' },
];
interface ConsumptionRecord {
id: string;
star_id?: string;
demand_id?: string;
demand_name?: string;
demander_name?: string;
brand?: string;
max_publish_count?: number;
product_name?: string;
product_information?: string;
}
const mockConsumptionRecords: ConsumptionRecord[] = [
{ id: 'C001', star_id: 'ST001', demand_id: 'DM001', demand_name: '产品推广任务', demander_name: 'XX品牌方', brand: 'XX品牌', max_publish_count: 10, product_name: 'XX产品', product_information: '产品信息描述' },
{ id: 'C002', star_id: 'ST002', demand_id: 'DM002', demand_name: '新品发布任务', demander_name: 'YY品牌方', brand: 'YY品牌', max_publish_count: 5, product_name: 'YY产品', product_information: '新品信息描述' },
{ id: 'C003', star_id: 'ST003', demand_id: 'DM003', demand_name: '品牌宣传任务', demander_name: 'ZZ品牌方', brand: 'ZZ品牌', max_publish_count: 8, product_name: 'ZZ产品', product_information: '品牌信息描述' },
{ id: 'C004', star_id: 'ST001', demand_id: 'DM004', demand_name: '活动促销任务', demander_name: 'XX品牌方', brand: 'XX品牌', max_publish_count: 15, product_name: 'XX活动产品', product_information: '活动产品信息' },
{ id: 'C005', star_id: 'ST004', demand_id: 'DM005', demand_name: '节日营销任务', demander_name: 'AA品牌方', brand: 'AA品牌', max_publish_count: 20, product_name: 'AA节日产品', product_information: '节日产品信息' },
];
const ConsumePage: React.FC = () => {
const navigate = useNavigate();
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>(mockConsumptionRecords);
const [loading, setLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [columnsData, setColumnsData] = useState<{ name: string; description: string }[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
useEffect(() => {
setLoading(true);
setTimeout(() => {
setConsumptionRecords(mockConsumptionRecords);
setLoading(false);
}, 500);
setTimeout(() => {
setColumnsData(mockColumnsData);
const saved = localStorage.getItem('consumeColumns');
if (saved) {
setSelectedColumns(JSON.parse(saved));
} else {
setSelectedColumns(mockColumnsData.map(item => item.name));
}
}, 300);
}, []);
const tableData = consumptionRecords.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
const handleBack = () => {
navigate('/authorization');
};
const dynamicColumns = columnsData
.filter(col => selectedColumns.includes(col.name))
.map(col => ({
title: col.description,
dataIndex: col.name,
key: col.name,
ellipsis: true,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}></Button>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}></Button>
</Space>
</div>
<Table
dataSource={tableData}
columns={dynamicColumns}
loading={loading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
pageSize: 10,
total: consumptionRecords.length,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
size: 'small',
}}
/>
</Card>
<Modal
title="自定义表头"
open={showModal}
onOk={() => {
localStorage.setItem('consumeColumns', JSON.stringify(selectedColumns));
message.success('表头设置已保存');
setShowModal(false);
}}
onCancel={() => {
setShowModal(false);
setSearchText('');
}}
okText="确定"
cancelText="取消"
width={800}
bodyStyle={{ padding: 16 }}
>
<div style={{ display: 'flex', gap: 20, height: 400 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Input
placeholder="搜索指标..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ marginBottom: 8 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, marginBottom: 12, display: 'block' }}>
</Typography.Text>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}></Typography.Text>
</div>
<Checkbox
checked={selectedColumns.length === columnsData.length && columnsData.length > 0}
indeterminate={selectedColumns.length > 0 && selectedColumns.length < columnsData.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(columnsData.map(item => item.name));
} else {
setSelectedColumns([]);
}
}}
style={{ marginBottom: 12 }}
>
</Checkbox>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{columnsData
.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase()) ||
item.name.toLowerCase().includes(searchText.toLowerCase())
)
.map(item => (
<Checkbox
key={item.name}
checked={selectedColumns.includes(item.name)}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(prev => [...prev, item.name]);
} else {
setSelectedColumns(prev => prev.filter(col => col !== item.name));
}
}}
>
{item.description}
</Checkbox>
))}
</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>
({selectedColumns.length})
</Typography.Text>
<Space>
<Button
size="small"
onClick={() => setSelectedColumns([])}
disabled={selectedColumns.length === 0}
>
</Button>
<Button
size="small"
onClick={() => {
message.info('更新表头字段');
setTimeout(() => {
setColumnsData(mockColumnsData);
}, 300);
}}
>
</Button>
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
{selectedColumns.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 14, textAlign: 'center', display: 'block', padding: '40px 0' }}>
</Typography.Text>
) : (
<div>
{selectedColumns.map((colName, index) => {
const col = columnsData.find(c => c.name === colName);
return (
<div
key={colName}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderBottom: '1px solid #f0f0f5',
cursor: 'move',
userSelect: 'none',
}}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('index', String(index));
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
const fromIndex = parseInt(e.dataTransfer.getData('index'));
const toIndex = index;
if (fromIndex !== toIndex) {
const newSelected = [...selectedColumns];
const [removed] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, removed);
setSelectedColumns(newSelected);
}
}}
>
<Typography.Text>{col?.description || colName}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{index + 1}
</Typography.Text>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</Modal>
</div>
);
};
export default ConsumePage;
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Input, Upload,
} from 'antd';
import {
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined,
} from '@ant-design/icons';
import { getOperationLogs } from '../api';
import { formatDate } from '../utils/formatDate';
interface OperationLog {
id: string;
userId: string;
username: string;
action: string;
method: string;
path: string;
detail?: string;
ip?: string;
createdAt: string;
}
const METHOD_COLORS: Record<string, string> = { POST: 'green', PUT: 'blue', DELETE: 'red' };
const AdminPlatform: React.FC = () => {
const [logs, setLogs] = useState<OperationLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState({ title: '', description: '', image: '' });
const load = async (p?: number) => {
setLoading(true);
try {
const res = await getOperationLogs(p || page);
setLogs(res.items || []);
setTotal(res.total || 0);
} catch {
message.error('加载平台管理失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const columns = [
{
title: '操作人', dataIndex: 'username', width: 120,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '操作', dataIndex: 'action', width: 160,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '方法', dataIndex: 'method', width: 80,
render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag>,
},
{
title: '路径', dataIndex: 'path', width: 220, ellipsis: true,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={() => load()}></Button>
</Space>
</div>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: 20,
total,
showTotal: (t) => `${t} 条记录`,
onChange: (p) => { setPage(p); load(p); },
}}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title="新增平台"
open={showModal}
onOk={() => {
message.success('新增成功');
setShowModal(false);
setFormData({ title: '', description: '', image: '' });
load();
}}
onCancel={() => {
setShowModal(false);
setFormData({ title: '', description: '', image: '' });
}}
okText="确认"
cancelText="取消"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}></Typography.Text>
<Input
placeholder="请输入标题"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
/>
</div>
<div>
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}></Typography.Text>
<Input.TextArea
placeholder="请输入描述"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={4}
/>
</div>
<div>
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}></Typography.Text>
<Upload
action="/api/upload"
listType="picture-card"
onChange={(info) => {
if (info.file.status === 'done') {
setFormData(prev => ({ ...prev, image: info.file.response?.url || '' }));
}
}}
>
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
</Upload>
</div>
</div>
</Modal>
</div>
);
};
export default AdminPlatform;
+94 -141
View File
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, Optional, List
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Depends, Body
@@ -6,11 +6,13 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.resources_material import ResourcesMaterial
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.dependencies import get_current_user, get_db
from app.dependencies import get_current_user, get_db, get_admin_user
from app.services.material_consumption_queue import sync_all_advertisers_consumption, _fetch_and_save_consumption
from app.services.material_consumption_service import get_consumption_list, format_consumption_response
router = APIRouter(prefix="/material-consumption", tags=["material-consumption"])
@@ -20,146 +22,26 @@ router = APIRouter(prefix="/material-consumption", tags=["material-consumption"]
summary="查询素材消耗列表",
description="查询当前用户的素材消耗列表",
)
async def get_consumption_list(
async def user_consumption_list(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
advertiser_id: Optional[str] = Query(None, description="广告主ID筛选"),
start_date: Optional[str] = Query(None, description="开始日期"),
end_date: Optional[str] = Query(None, description="结束日期"),
consume_date: Optional[List[str]] = Query(None, description="消耗日期范围,格式: ['开始日期','结束日期']"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
offset = (page - 1) * page_size
query = (
select(MaterialCost)
.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
)
.where(
UserOAuth.user_id == current_user.id,
MaterialCost.deleted_at.is_(None),
UserOAuth.deleted_at.is_(None),
)
consumptions, total = await get_consumption_list(
db=db,
page=page,
page_size=page_size,
advertiser_id=advertiser_id,
consume_date=consume_date,
current_user=current_user,
)
if advertiser_id:
query = query.where(MaterialCost.advertiser_id == advertiser_id)
if start_date:
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
query = query.where(MaterialCost.consume_date >= start_date_obj)
if end_date:
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date()
query = query.where(MaterialCost.consume_date <= end_date_obj)
result = await db.execute(
query.order_by(MaterialCost.consume_date.desc())
.offset(offset)
.limit(page_size)
)
consumptions = result.scalars().all()
count_query = (
select(func.count(MaterialCost.id))
.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
)
.where(
UserOAuth.user_id == current_user.id,
MaterialCost.deleted_at.is_(None),
UserOAuth.deleted_at.is_(None),
)
)
if advertiser_id:
count_query = count_query.where(MaterialCost.advertiser_id == advertiser_id)
if start_date:
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
count_query = count_query.where(MaterialCost.consume_date >= start_date_obj)
if end_date:
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date()
count_query = count_query.where(MaterialCost.consume_date <= end_date_obj)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
return {
"code": 0,
"data": [
{
"id": consumption.id,
"advertiser_id": consumption.advertiser_id,
"material_id": consumption.material_id,
"consume_date": consumption.consume_date.isoformat() if consumption.consume_date else None,
"stat_cost": consumption.stat_cost,
"show_cnt": consumption.show_cnt,
"cpm_platform": consumption.cpm_platform,
"click_cnt": consumption.click_cnt,
"ctr": consumption.ctr,
"cpc_platform": consumption.cpc_platform,
"convert_cnt": consumption.convert_cnt,
"conversion_cost": consumption.conversion_cost,
"conversion_rate": consumption.conversion_rate,
"deep_convert_cnt": consumption.deep_convert_cnt,
"deep_convert_cost": consumption.deep_convert_cost,
"deep_convert_rate": consumption.deep_convert_rate,
"active": consumption.active,
"active_cost": consumption.active_cost,
"active_rate": consumption.active_rate,
"active_register": consumption.active_register,
"active_register_cost": consumption.active_register_cost,
"active_register_rate": consumption.active_register_rate,
"attribution_next_day_open_cnt": consumption.attribution_next_day_open_cnt,
"attribution_next_day_open_cost": consumption.attribution_next_day_open_cost,
"attribution_next_day_open_rate": consumption.attribution_next_day_open_rate,
"active_pay": consumption.active_pay,
"active_pay_cost": consumption.active_pay_cost,
"active_pay_rate": consumption.active_pay_rate,
"phone": consumption.phone,
"form": consumption.form,
"download_start": consumption.download_start,
"form_submit": consumption.form_submit,
"button": consumption.button,
"view": consumption.view,
"message": consumption.message,
"consult": consumption.consult,
"consult_effective": consumption.consult_effective,
"shopping": consumption.shopping,
"customer_effective": consumption.customer_effective,
"attribution_game_in_app_ltv_1day": consumption.attribution_game_in_app_ltv_1day,
"attribution_game_in_app_roi_1day": consumption.attribution_game_in_app_roi_1day,
"loan_completion": consumption.loan_completion,
"loan_completion_cost": consumption.loan_completion_cost,
"loan_completion_rate": consumption.loan_completion_rate,
"loan_credit": consumption.loan_credit,
"loan_credit_cost": consumption.loan_credit_cost,
"loan_credit_rate": consumption.loan_credit_rate,
"in_app_order_gmv": consumption.in_app_order_gmv,
"in_app_order_roi": consumption.in_app_order_roi,
"in_app_pay_gmv": consumption.in_app_pay_gmv,
"in_app_pay_roi": consumption.in_app_pay_roi,
"total_play": consumption.total_play,
"valid_play": consumption.valid_play,
"valid_play_cost": consumption.valid_play_cost,
"valid_play_rate": consumption.valid_play_rate,
"valid_play_of_mille": consumption.valid_play_of_mille,
"valid_play_cost_of_mille": consumption.valid_play_cost_of_mille,
"average_play_time_per_play": consumption.average_play_time_per_play,
"play_over_rate": consumption.play_over_rate,
"dy_like": consumption.dy_like,
"dy_comment": consumption.dy_comment,
"dy_share": consumption.dy_share,
"report_cnt": consumption.report_cnt,
"created_at": consumption.created_at,
}
for consumption in consumptions
],
"data": format_consumption_response(consumptions),
"pagination": {
"page": page,
"page_size": page_size,
@@ -171,27 +53,55 @@ async def get_consumption_list(
@router.get(
"/sync",
summary="手动同步素材消耗",
description="手动触发素材消耗同步任务,加入队列顺序执行",
description="手动触发当前用户授权的广告主的素材消耗同步任务,加入队列顺序执行",
)
async def sync_consumption(
date: str = Query(None, description="同步日期,默认为昨天"),
advertiser_id: Optional[str] = Query(None, description="指定广告主ID,不指定则同步所有授权的广告主"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
if date is None:
date = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
# 测试用,手动触发同步素材消耗任务
result = await _fetch_and_save_consumption("0019eb9f130027c05b8", "1863675913228435", date)
return {
"code": 0,
"message": result,
}
result = await sync_all_advertisers_consumption(date)
from app.services.material_consumption_queue import material_consumption_queue
query = (
select(UserOAuth.id, UserOAuthAccount.advertiser_id)
.join(UserOAuthAccount, UserOAuth.id == UserOAuthAccount.oauth_id)
.join(ResourcesMaterial, UserOAuthAccount.advertiser_id == ResourcesMaterial.advertiser_id)
.where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
UserOAuthAccount.deleted_at.is_(None),
ResourcesMaterial.deleted_at.is_(None),
ResourcesMaterial.material_id.is_not_(None),
)
.distinct()
)
if advertiser_id:
query = query.where(UserOAuthAccount.advertiser_id == advertiser_id)
result = await db.execute(query)
oauth_advertiser_pairs = result.all()
if not oauth_advertiser_pairs:
return {
"code": 0,
"message": "没有找到可同步的广告主",
}
for oauth_id, adv_id in oauth_advertiser_pairs:
await material_consumption_queue.enqueue({
"oauth_id": oauth_id,
"advertiser_id": adv_id,
"date": date,
})
return {
"code": 0,
"message": result["message"],
"message": f"已将 {len(oauth_advertiser_pairs)} 个广告主的消耗更新任务加入队列",
}
@@ -298,4 +208,47 @@ async def get_advertisers(
return {
"code": 0,
"data": [{"advertiser_id": str(aid)} for aid in advertisers],
}
@router.get(
"/admin/list",
summary="管理员查询所有素材消耗列表",
description="管理员可查看系统中所有用户的素材消耗数据",
)
async def admin_consumption_list(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
advertiser_id: Optional[str] = Query(None, description="广告主ID筛选"),
user_id: Optional[str] = Query(None, description="用户ID筛选"),
consume_date: Optional[List[str]] = Query(None, description="消耗日期范围,格式: ['开始日期','结束日期']"),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
#需要判断管理员吗
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员才能查询所有素材消耗列表",
)
consumptions, total = await get_consumption_list(
db=db,
page=page,
page_size=page_size,
advertiser_id=advertiser_id,
user_id=user_id,
consume_date=consume_date,
current_user=None,
)
return {
"code": 0,
"data": format_consumption_response(consumptions, include_oauth_id=True),
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
+37 -6
View File
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, Optional, List
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import select, func
@@ -18,6 +18,37 @@ from app.utils.id_gen import generate_id
router = APIRouter(prefix="/open-type", tags=["open-type"])
# 公共接口 - 无需登录
@router.get(
"/open_type_all",
summary="查询所有开户方式",
description="查询所有开户方式,前端使用select框选择,无需登录",
dependencies=[],
)
async def get_open_type_public(
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(OpenType).where(OpenType.deleted_at.is_(None))
)
open_types = result.scalars().all()
return {
"code": 0,
"message": "查询成功",
"data": [
{
"id": ot.id,
"type_name": ot.type_name,
"open_type": ot.open_type,
"description": ot.description,
"thumb": ot.thumb,
}
for ot in open_types
],
}
@router.get(
"/list",
summary="获取开户方式列表",
@@ -83,7 +114,7 @@ async def get_open_type_list(
@router.get(
"/{id}",
"/select/{id}",
summary="获取开户方式详情",
description="根据ID获取开户方式详情",
response_model=OpenTypeResponse,
@@ -120,7 +151,7 @@ async def get_open_type_detail(
@router.post(
"/",
"/create",
summary="创建开户方式",
description="创建新的开户方式",
response_model=OpenTypeResponse,
@@ -167,7 +198,7 @@ async def create_open_type(
@router.put(
"/{id}",
"/update/{id}",
summary="更新开户方式",
description="更新指定的开户方式",
response_model=OpenTypeResponse,
@@ -231,7 +262,7 @@ async def update_open_type(
@router.delete(
"/{id}",
"/delete/{id}",
summary="删除开户方式",
description="软删除指定的开户方式",
response_model=OpenTypeResponse,
@@ -259,4 +290,4 @@ async def delete_open_type(
"code": 0,
"message": "删除成功",
"data": None,
}
}
@@ -0,0 +1,160 @@
from typing import Any, Optional, Tuple, List
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
async def get_consumption_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
advertiser_id: Optional[str] = None,
user_id: Optional[str] = None,
consume_date: Optional[List[str]] = None,
current_user: Optional[User] = None,
) -> Tuple[List[MaterialCost], int]:
offset = (page - 1) * page_size
query = select(MaterialCost).where(MaterialCost.deleted_at.is_(None))
if current_user:
query = query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
)
if user_id:
query = query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(UserOAuth.user_id == user_id)
if advertiser_id:
query = query.where(MaterialCost.advertiser_id == advertiser_id)
if consume_date and len(consume_date) >= 2:
start_date_obj = datetime.strptime(consume_date[0], "%Y-%m-%d").date()
end_date_obj = datetime.strptime(consume_date[1], "%Y-%m-%d").date()
query = query.where(MaterialCost.consume_date >= start_date_obj)
query = query.where(MaterialCost.consume_date <= end_date_obj)
result = await db.execute(
query.order_by(MaterialCost.consume_date.desc())
.offset(offset)
.limit(page_size)
)
consumptions = result.scalars().all()
count_query = select(func.count(MaterialCost.id)).where(MaterialCost.deleted_at.is_(None))
if current_user:
count_query = count_query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
)
if user_id:
count_query = count_query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(UserOAuth.user_id == user_id)
if advertiser_id:
count_query = count_query.where(MaterialCost.advertiser_id == advertiser_id)
if consume_date and len(consume_date) >= 2:
start_date_obj = datetime.strptime(consume_date[0], "%Y-%m-%d").date()
end_date_obj = datetime.strptime(consume_date[1], "%Y-%m-%d").date()
count_query = count_query.where(MaterialCost.consume_date >= start_date_obj)
count_query = count_query.where(MaterialCost.consume_date <= end_date_obj)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
return consumptions, total
def format_consumption_response(consumptions: List[MaterialCost], include_oauth_id: bool = False) -> List[dict]:
result = []
for consumption in consumptions:
item = {
"id": consumption.id,
"advertiser_id": consumption.advertiser_id,
"material_id": consumption.material_id,
"consume_date": consumption.consume_date.isoformat() if consumption.consume_date else None,
"stat_cost": consumption.stat_cost,
"show_cnt": consumption.show_cnt,
"cpm_platform": consumption.cpm_platform,
"click_cnt": consumption.click_cnt,
"ctr": consumption.ctr,
"cpc_platform": consumption.cpc_platform,
"convert_cnt": consumption.convert_cnt,
"conversion_cost": consumption.conversion_cost,
"conversion_rate": consumption.conversion_rate,
"deep_convert_cnt": consumption.deep_convert_cnt,
"deep_convert_cost": consumption.deep_convert_cost,
"deep_convert_rate": consumption.deep_convert_rate,
"active": consumption.active,
"active_cost": consumption.active_cost,
"active_rate": consumption.active_rate,
"active_register": consumption.active_register,
"active_register_cost": consumption.active_register_cost,
"active_register_rate": consumption.active_register_rate,
"attribution_next_day_open_cnt": consumption.attribution_next_day_open_cnt,
"attribution_next_day_open_cost": consumption.attribution_next_day_open_cost,
"attribution_next_day_open_rate": consumption.attribution_next_day_open_rate,
"active_pay": consumption.active_pay,
"active_pay_cost": consumption.active_pay_cost,
"active_pay_rate": consumption.active_pay_rate,
"phone": consumption.phone,
"form": consumption.form,
"download_start": consumption.download_start,
"form_submit": consumption.form_submit,
"button": consumption.button,
"view": consumption.view,
"message": consumption.message,
"consult": consumption.consult,
"consult_effective": consumption.consult_effective,
"shopping": consumption.shopping,
"customer_effective": consumption.customer_effective,
"attribution_game_in_app_ltv_1day": consumption.attribution_game_in_app_ltv_1day,
"attribution_game_in_app_roi_1day": consumption.attribution_game_in_app_roi_1day,
"loan_completion": consumption.loan_completion,
"loan_completion_cost": consumption.loan_completion_cost,
"loan_completion_rate": consumption.loan_completion_rate,
"loan_credit": consumption.loan_credit,
"loan_credit_cost": consumption.loan_credit_cost,
"loan_credit_rate": consumption.loan_credit_rate,
"in_app_order_gmv": consumption.in_app_order_gmv,
"in_app_order_roi": consumption.in_app_order_roi,
"in_app_pay_gmv": consumption.in_app_pay_gmv,
"in_app_pay_roi": consumption.in_app_pay_roi,
"total_play": consumption.total_play,
"valid_play": consumption.valid_play,
"valid_play_cost": consumption.valid_play_cost,
"valid_play_rate": consumption.valid_play_rate,
"valid_play_of_mille": consumption.valid_play_of_mille,
"valid_play_cost_of_mille": consumption.valid_play_cost_of_mille,
"average_play_time_per_play": consumption.average_play_time_per_play,
"play_over_rate": consumption.play_over_rate,
"dy_like": consumption.dy_like,
"dy_comment": consumption.dy_comment,
"dy_share": consumption.dy_share,
"report_cnt": consumption.report_cnt,
"created_at": consumption.created_at,
}
if include_oauth_id:
item["oauth_id"] = consumption.oauth_id
result.append(item)
return result
+2 -2
View File
@@ -1,5 +1,5 @@
#VITE_API_BASE=http://localhost:8000
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_API_BASE=http://192.168.120.17:8000
# VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-ZwwuCCw2.js"></script>
<script type="module" crossorigin src="/assets/index-DTCz7Wbv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
</head>
<body>
+37 -3
View File
@@ -501,8 +501,11 @@ export async function createShotReplication(params: any): Promise<any> {
return api.post('/shot-replications/task-sets', params);
}
// 获取镜头复刻任务列表
export async function getShotReplicationList(page: number, page_size: number): Promise<any> {
return api.get(`/shot-replications/task-sets?page=${page}&page_size=${page_size}`);
export async function getShotReplicationList(page: number, page_size: number, keyword?: string): Promise<any> {
const url = keyword
? `/shot-replications/task-sets?page=${page}&page_size=${page_size}&keyword=${encodeURIComponent(keyword)}`
: `/shot-replications/task-sets?page=${page}&page_size=${page_size}`;
return api.get(url);
}
// 获取镜头复刻任务详情
export async function getShotReplicationDetail(taskSetId: string): Promise<any> {
@@ -581,4 +584,35 @@ export async function getArea(params?: GetAreaParams): Promise<any> {
if (params?.parent_code) query.set('parent_code', params.parent_code);
return api.get(`/pre-test-template/getArea?${query.toString()}`);
}
//
// ── Upload Material ───────────────────────────────────────
export interface AsyncBatchUploadTask {
advertiser_ids: string[];
resource_ids: string[];
oauth_id: string;
}
export interface AsyncBatchUploadParams {
tasks: AsyncBatchUploadTask[];
}
export async function asyncBatchUploadMaterial(params: AsyncBatchUploadParams): Promise<any> {
return api.post('/upload-material/async-batch-upload', params);
}
// 获取上传历史
// /api/upload-material/upload-history
export interface UploadHistoryParams {
status?: string;
page?: number;
pageSize?: number;
}
export async function getUploadHistory(params: UploadHistoryParams): Promise<any> {
const searchParams = new URLSearchParams();
if (params.status) searchParams.set('status', params.status);
if (params.page) searchParams.set('page', String(params.page));
if (params.pageSize) searchParams.set('page_size', String(params.pageSize));
const query = searchParams.toString();
return api.get(`/upload-material/upload-history${query ? `?${query}` : ''}`);
}
@@ -552,23 +552,27 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
<Text style={{ marginLeft: 8, color: '#64748b' }}>...</Text>
</div>
) : frames.length > 0 ? (
frames.map((frame) => (
<button
key={frame.second}
type="button"
onClick={() => handleFrameClick(frame.second)}
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
style={{
minWidth: 0,
height: 86,
border: 'none',
padding: 0,
background: 'transparent',
overflow: 'hidden',
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
position: 'relative',
}}
>
frames.map((frame) => {
const isSelected = frame.second >= range[0] && frame.second < range[1];
return (
<button
key={frame.second}
type="button"
onClick={() => handleFrameClick(frame.second)}
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
style={{
minWidth: 0,
height: 86,
borderTop: isSelected ? '5px solid #6366f1' : 'none',
borderBottom: isSelected ? '5px solid #6366f1' : 'none',
padding: 0,
background: 'transparent',
overflow: 'hidden',
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
position: 'relative',
// borderRadius: 4,
}}
>
{frame.status === 'success' && frame.image ? (
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : frame.status === 'loading' ? (
@@ -594,7 +598,8 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
{frame.second}s
</span>
</button>
))
);
})
) : (
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
@@ -603,7 +608,7 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
</div>
<div style={{ position: 'absolute', top: -6, left: 10, right: 0, padding: '0 8px 0' }}>
<div style={{ position: 'absolute', top: -6, left: 6, right: 0, }}>
<Slider
range
min={0}
@@ -65,7 +65,6 @@ const AuthorizationPage: React.FC = () => {
useEffect(() => {
loadOAuthList();
handleCallback();
}, []);
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
@@ -97,22 +96,6 @@ const AuthorizationPage: React.FC = () => {
}
};
const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const authCode = params.get('auth_code');
const state = params.get('state');
if (authCode && state) {
try {
await juliang_callback({ auth_code: authCode, state });
message.success('授权成功');
window.history.replaceState({}, document.title, window.location.pathname);
loadOAuthList();
} catch (error) {
message.error('授权回调失败');
}
}
};
const handleAuthorize = () => {
setShowModal(true);
};
+51 -42
View File
@@ -1070,42 +1070,7 @@ const AIChatPage: React.FC = () => {
<div style={{ flex: 1 }}>
{/* 时间戳和参数信息 */}
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
{/* 消息气泡 */}
<div
style={{
@@ -1114,13 +1079,17 @@ const AIChatPage: React.FC = () => {
borderRadius: '16px 16px 16px 4px',
padding: '12px 16px',
width: '70%',
maxWidth: 800,
minWidth: 500,
boxSizing: 'border-box',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.08), 0 1px 3px rgba(0,0,0,0.04)',
position: 'relative',
border: '1px solid rgba(99, 102, 241, 0.06)',
}}
>
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
</div>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
<Popconfirm
@@ -1173,7 +1142,7 @@ const AIChatPage: React.FC = () => {
</div>
{/* 文本内容 */}
<div
{/* <div
style={{
width: '100%',
position: 'relative',
@@ -1204,7 +1173,7 @@ const AIChatPage: React.FC = () => {
});
}}
>
{/* 默认显示:一行省略 */}
默认显示:一行省略
<div style={{
width: '100%',
overflow: 'hidden',
@@ -1215,7 +1184,7 @@ const AIChatPage: React.FC = () => {
{msg.originalPrompt}
</div>
{/* 鼠标移入显示:完整内容 */}
鼠标移入显示:完整内容
<div style={{
width: '100%',
maxHeight: 200,
@@ -1225,7 +1194,7 @@ const AIChatPage: React.FC = () => {
}}>
{msg.originalPrompt}
</div>
</div>
</div> */}
{/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */}
@@ -1304,14 +1273,14 @@ const AIChatPage: React.FC = () => {
)}
{/* 已完成 - 显示媒体内容 */}
{msg.status === 'completed' && (
<div style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
<div style={{ display: 'flex', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
<div
onClick={() => {
setPreviewUrl(msg.genType === 'image' ? msg.imageUrl : msg.videoUrl);
setPreviewType(msg.genType === 'image' ? 'image' : 'video');
setPreviewVisible(true);
}}
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
style={{ width: '50%', cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
>
{msg.genType === 'image' ? (
<img
@@ -1353,6 +1322,46 @@ const AIChatPage: React.FC = () => {
</>
)}
</div>
<div style={{ width: '50%', }}>
<p style={{ marginBottom: 20 ,height: 100, overflow: 'auto', padding: 0, margin: 0 }}>
{msg.originalPrompt}
</p>
<div style={{fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
</div>
</div>
)}
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -57,6 +57,8 @@ function InitialInfo() {
const [taskDetail, setTaskDetail] = useState<any>({});
// API 返回的步骤数据
const [apiSteps, setApiSteps] = useState<any[]>([]);
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
//
const baseSteps = [
@@ -76,6 +78,21 @@ function InitialInfo() {
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
}));
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
useEffect(() => {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
setActiveKey([String(steps[i].childId)]);
return;
}
}
setActiveKey([]);
}, [apiSteps]);
// console.log('steps', steps);
@@ -367,7 +384,7 @@ function InitialInfo() {
}
const agincreatevideo = () => {
let params = {
engine_id: steps[2].engineId,
engine_id: steps[3].engineId,
}
@@ -553,13 +570,14 @@ function InitialInfo() {
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 16px' }}>
<Collapse
defaultActiveKey={['']}
activeKey={activeKey}
onChange={setActiveKey}
ghost
bordered={false}
style={{ background: 'transparent' }}
expandIconPlacement="end"
items={steps.map((step) => ({
key: String(step.id),
key: String(step.childId),
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+30 -8
View File
@@ -17,6 +17,7 @@ export default function VideoFrameExtractor() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -81,8 +82,21 @@ export default function VideoFrameExtractor() {
idempotency_key: `shot_${Date.now()}`,
};
await createShotReplication(params);
message.success('任务创建成功');
createShotReplication(params).then((res) => {
// message.success('任务创建成功');
getShotReplicationList(1, 20, searchKeyword).then((res) => {
// console.log('获取列表成功', res.items[0].id);
message.loading('创建中...', 3);
setTimeout(() => {
navigate(`/removelens/${res.items[0].id}/removeinfo`);
}, 3000);
// setTableData(res.items || []);
})
})
cleanupResources();
} catch (err) {
message.error('任务创建失败,请重试');
@@ -91,9 +105,9 @@ export default function VideoFrameExtractor() {
}
};
const fetchList = async (page: number, size: number) => {
const fetchList = async (page: number, size: number, keyword?: string) => {
try {
const res = await getShotReplicationList(page, size);
const res = await getShotReplicationList(page, size, keyword);
setTableData(res.items || []);
setTotal(res.total || 0);
setCurrentPage(page);
@@ -104,12 +118,16 @@ export default function VideoFrameExtractor() {
};
const handlePageChange = (page: number, size: number) => {
fetchList(page, size);
fetchList(page, size, searchKeyword);
};
const handleSearch = () => {
fetchList(1, 10, searchKeyword);
};
const handleOpenModal = () => {
setIsModalOpen(true);
fetchList(1, 10);
fetchList(1, 10, searchKeyword);
};
return (
@@ -127,7 +145,7 @@ export default function VideoFrameExtractor() {
<div style={{ maxWidth: 1200, margin: '50px auto', position: 'relative', zIndex: 10 }}>
<div style={{ marginBottom: 50,height: 160, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ marginBottom: 50, height: 160, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 40, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<div>
@@ -440,13 +458,16 @@ export default function VideoFrameExtractor() {
width={800}
footer={null}
styles={{
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
header: { background: '#fff', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', padding: '20px 24px' },
}}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 200,
borderRadius: 10,
@@ -456,6 +477,7 @@ export default function VideoFrameExtractor() {
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
height: 40,
+32 -8
View File
@@ -52,10 +52,13 @@ function InitialInfo() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
// 任务详情数据
const [taskDetail, setTaskDetail] = useState<any>({});
// API 返回的步骤数据
const [apiSteps, setApiSteps] = useState<any[]>([]);
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
//
const baseSteps = [
@@ -75,6 +78,17 @@ function InitialInfo() {
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
}));
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
useEffect(() => {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
setActiveKey([String(steps[i].childId)]);
return;
}
}
setActiveKey([]);
}, [apiSteps]);
@@ -82,8 +96,8 @@ function InitialInfo() {
// 获取列表数据的函数
const fetchList = (page: number, size: number) => {
getShotReplicationList(page, size).then((res: any) => {
const fetchList = (page: number, size: number, keyword?: string) => {
getShotReplicationList(page, size, keyword).then((res: any) => {
if (res.items) {
setTableData(res.items);
}
@@ -98,12 +112,17 @@ function InitialInfo() {
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
fetchList(page, size);
fetchList(page, size, searchKeyword);
};
// 搜索处理
const handleSearch = () => {
fetchList(1, 20, searchKeyword);
};
// 获取复刻列表数据
useEffect(() => {
fetchList(currentPage, pageSize);
fetchList(currentPage, pageSize, searchKeyword);
}, []);
// 获取引擎列表
@@ -371,7 +390,7 @@ function InitialInfo() {
}
const agincreatevideo = () => {
let params = {
engine_id: steps[2].engineId,
engine_id: steps[3].engineId,
}
@@ -422,7 +441,7 @@ function InitialInfo() {
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
onClick={() => navigate('/removelens')}
style={{
color: '#64748b',
borderRadius: 10,
@@ -551,13 +570,14 @@ function InitialInfo() {
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 16px' }}>
<Collapse
defaultActiveKey={['']}
activeKey={activeKey}
onChange={setActiveKey}
ghost
bordered={false}
style={{ background: 'transparent' }}
expandIconPlacement="end"
items={steps.map((step) => ({
key: String(step.id),
key: String(step.childId),
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -1199,6 +1219,9 @@ function InitialInfo() {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 200,
borderRadius: 10,
@@ -1208,6 +1231,7 @@ function InitialInfo() {
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
height: 40,