Merge branch 'main' of gitee.com:wg123/video-gen

This commit is contained in:
Lrd
2026-06-15 17:48:06 +08:00
27 changed files with 3075 additions and 1118 deletions
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-CIsX_bex.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-C93H_elA.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+16 -3
View File
@@ -86,8 +86,12 @@ export async function markNotificationRead(id: string): Promise<void> {
// ── Admin ─────────────────────────────────────────────────
export async function getAdminStats(): Promise<AdminStats> {
return api.get('/admin/stats');
export async function getAdminStats(startDate?: string, endDate?: string): Promise<AdminStats> {
const params: Record<string, string> = {};
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
const query = new URLSearchParams(params).toString();
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
}
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
@@ -139,10 +143,19 @@ export async function uploadPdf(file: File, configKey: string): Promise<{ url: s
return res.json();
}
export async function getCreditRecords(filters?: { user_id?: string; type?: string }): Promise<any> {
export async function getCreditRecords(filters?: {
user_id?: string;
user_name?: string;
type?: string;
start_date?: string;
end_date?: string;
}): Promise<any> {
const params = new URLSearchParams();
if (filters?.user_id) params.set('user_id', filters.user_id);
if (filters?.user_name) params.set('user_name', filters.user_name);
if (filters?.type) params.set('type', filters.type);
if (filters?.start_date) params.set('start_date', filters.start_date);
if (filters?.end_date) params.set('end_date', filters.end_date);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Select, Space, Table, Tag, Typography, message,
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
} from '@ant-design/icons';
import { getCreditRecords } from '../api';
import { formatDate } from '../utils/formatDate';
import dayjs from 'dayjs';
interface CreditRecord {
id: string;
@@ -29,11 +30,18 @@ const AdminCreditRecords: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string>('');
const [userNameFilter, setUserNameFilter] = useState<string>('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const load = async (type?: string) => {
const load = async () => {
setLoading(true);
try {
const res = await getCreditRecords(type ? { type } : undefined);
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string } = {};
if (typeFilter) filters.type = typeFilter;
if (userNameFilter) filters.user_name = userNameFilter;
if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD');
if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD');
const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined);
setRecords(res.items || []);
setTotal(res.total || 0);
} catch {
@@ -45,9 +53,15 @@ const AdminCreditRecords: React.FC = () => {
useEffect(() => { load(); }, []);
const handleTypeFilter = (value: string) => {
setTypeFilter(value);
load(value || undefined);
const handleSearch = () => {
load();
};
const handleReset = () => {
setTypeFilter('');
setUserNameFilter('');
setDateRange([null, null]);
load();
};
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
@@ -139,11 +153,11 @@ const AdminCreditRecords: React.FC = () => {
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select
value={typeFilter}
onChange={handleTypeFilter}
onChange={(value) => setTypeFilter(value)}
style={{ width: 120 }}
options={[
{ value: '', label: '全部类型' },
@@ -152,8 +166,25 @@ const AdminCreditRecords: React.FC = () => {
{ value: 'refund', label: '退回' },
]}
/>
<Input
placeholder="用户名搜索"
value={userNameFilter}
onChange={(e) => setUserNameFilter(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
placeholder={['开始日期', '结束日期']}
style={{ width: 250 }}
/>
</Space>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => load(typeFilter || undefined)}></Button>
</div>
<Table
columns={columns}
+389 -66
View File
@@ -1,90 +1,413 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd';
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
ThunderboltOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { getAdminStats } from '../api';
import type { AdminStats } from '../types';
import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats, SystemConfig } from '../types';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
const [siteName, setSiteName] = useState<string>('数据概览');
const load = async () => {
setLoading(true);
try {
const start = startDate[0]?.format('YYYY-MM-DD') || undefined;
const end = startDate[1]?.format('YYYY-MM-DD') || undefined;
const data = await getAdminStats(start, end);
setStats(data);
} catch { /* auth error handled by client */ }
setLoading(false);
};
const loadSiteName = async () => {
try {
const configs = await getSystemConfigs();
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
if (siteConfig) {
const title = `${siteConfig.value} 管理后台`;
setSiteName(title);
document.title = title;
}
} catch {
setSiteName('数据概览');
document.title = '数据概览';
}
};
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const data = await getAdminStats();
setStats(data);
} catch { /* auth error handled by client */ }
setLoading(false);
};
load();
loadSiteName();
}, []);
const statCards = stats ? [
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
const handleToday = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleYesterday = () => {
const yesterday = dayjs().subtract(1, 'day');
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
setStartDate(dates);
loadWithDates(dates);
};
const handleWeek = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleMonth = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
setLoading(true);
getAdminStats(start, end).then(data => {
setStats(data);
setLoading(false);
}).catch(() => {
setLoading(false);
});
};
const handleDateChange = (dates: any) => {
if (dates) {
setStartDate([dates[0], dates[1]]);
loadWithDates([dates[0], dates[1]]);
}
};
const baseStats = stats ? [
{
title: '用户数量',
value: stats.totalUsers,
lastPeriodValue: stats.lastPeriodUsers,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
prefix: '',
suffix: '位用户',
description: '平台注册用户总数'
},
{
title: '总项目数',
value: stats.totalProjects,
lastPeriodValue: stats.lastPeriodProjects,
icon: <ProjectOutlined />,
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
prefix: '',
suffix: '个项目',
description: '创建的项目总数'
},
{
title: '项目记录',
value: stats.totalRecords,
lastPeriodValue: stats.lastPeriodRecords,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
prefix: '',
suffix: '条记录',
description: '项目记录总数'
},
{
title: '创作记录',
value: stats.totalGenerations,
lastPeriodValue: stats.lastPeriodGenerations,
icon: <PlayCircleOutlined />,
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
prefix: '',
suffix: '次创作',
description: 'AI创作记录总数'
},
] : [];
return (
<div>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((s, i) => (
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
<Card bordered={false} loading={loading}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: s.bg, display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: s.color, flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
const financeStats = stats ? [
{
title: '支付宝收入',
value: stats.todayAlipayRevenue,
icon: <WalletOutlined />,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
prefix: '¥',
suffix: '',
description: '支付宝收款',
tag: '支付宝'
},
{
title: '微信收入',
value: stats.todayWechatRevenue,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
prefix: '¥',
suffix: '',
description: '微信收款',
tag: '微信支付'
},
{
title: '总收入',
value: stats.totalRevenue,
lastPeriodValue: stats.lastPeriodRevenue,
icon: <ArrowUpOutlined />,
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
prefix: '¥',
suffix: '',
description: '平台总收入',
tag: '总收入'
},
{
title: '消耗积分',
value: stats.creditsConsumedToday,
lastPeriodValue: stats.lastPeriodCreditsConsumed,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
prefix: '',
suffix: '积分',
description: '用户消耗积分',
tag: '积分消耗'
},
] : [];
{/* Quick Info */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="系统信息" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ label: '平台名称', value: 'VideoGen.AI' },
{ label: 'API版本', value: 'v1.0.0' },
{ label: '数据库', value: 'PostgreSQL' },
{ label: '视频引擎', value: 'Seedance 2.0' },
].map(item => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<Typography.Text type="secondary">{item.label}</Typography.Text>
<Typography.Text strong>{item.value}</Typography.Text>
</div>
))}
const StatCard: React.FC<{
title: string;
value: number;
lastPeriodValue?: number;
icon: React.ReactNode;
gradient: string;
prefix?: string;
suffix?: string;
description?: string;
tag?: string;
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
: null;
const isPositive = change !== null && parseFloat(change) >= 0;
return (
<Card
bordered={false}
loading={loading}
hoverable
style={{
borderRadius: 16,
border: '1px solid rgba(0,0,0,0.04)',
background: '#ffffff',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
overflow: 'hidden'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 0'
}}>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
flexShrink: 0,
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 4
}}>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
{tag && (
<span style={{
fontSize: 10,
padding: '2px 8px',
borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
color: '#6366f1',
fontWeight: 500
}}>
{tag}
</span>
)}
</div>
</Card>
</Col>
</Row>
<div style={{
fontSize: 28,
fontWeight: 800,
color: '#1e293b',
letterSpacing: -0.5,
marginBottom: 2
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
{description && (
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
{description}
</Typography.Text>
)}
{change !== null && lastPeriodValue !== undefined && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
</Typography.Text>
<span style={{
fontSize: 11,
fontWeight: 500,
color: isPositive ? '#10b981' : '#ef4444',
display: 'flex',
alignItems: 'center',
gap: 2
}}>
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
</span>
</div>
)}
</div>
</div>
</Card>
);
};
return (
<div style={{ padding: 0 }}>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px 24px 32px',
borderRadius: 0,
marginBottom: -24,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%'
}} />
<div style={{
position: 'absolute',
bottom: -30,
left: -30,
width: 150,
height: 150,
background: 'rgba(255,255,255,0.08)',
borderRadius: '50%'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
{siteName}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
</Typography.Text>
</div>
</div>
<div style={{ marginTop: 40 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<Space>
<Button
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
size="small"
onClick={handleToday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleYesterday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleWeek}
>
</Button>
<Button
type="default"
size="small"
onClick={handleMonth}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
<DatePicker.RangePicker
value={startDate}
onChange={handleDateChange}
placeholder={['开始日期', '结束日期']}
size="small"
/>
</div>
</div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{baseStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
</Row>
</div>
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{financeStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
</Row>
</div>
</div>
);
};
@@ -763,81 +763,76 @@ const AdminGenerationAiRecords: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card
title={(
<Space>
<FileImageOutlined />
<span></span>
</Space>
)}
extra={(
<Space wrap>
<Select
allowClear
placeholder="状态筛选"
value={filterStatus || undefined}
style={{ width: 140 }}
onChange={(v) => {
setFilterStatus(v || '');
setPage(1);
}}
options={[
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="类型筛选"
value={filterGenType || undefined}
style={{ width: 120 }}
onChange={(v) => {
setFilterGenType(v || '');
setPage(1);
}}
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Input
placeholder="用户ID"
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 180 }}
allowClear
/>
<Input
placeholder="用户名"
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 160 }}
allowClear
/>
<Button icon={<SearchOutlined />} onClick={handleSearch}></Button>
</Space>
)}
bordered={false}
style={{ borderRadius: 16 }}
>
<div>
<Card bordered={false} 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' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Space>
<Select
allowClear
placeholder="状态筛选"
value={filterStatus || undefined}
style={{ width: 140 }}
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
options={[
{ value: 'generating', label: '生成中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="类型筛选"
value={filterGenType || undefined}
style={{ width: 120 }}
onChange={(v) => { setFilterGenType(v || ''); setPage(1); }}
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }}
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Input
placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 160 }}
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
rowKey="id"
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
</Card>
+9
View File
@@ -113,8 +113,17 @@ export interface AdminStats {
totalUsers: number;
totalProjects: number;
totalGenerations: number;
totalRecords: number;
totalRevenue: number;
creditsConsumedToday: number;
todayAlipayRevenue: number;
todayWechatRevenue: number;
lastPeriodUsers: number;
lastPeriodProjects: number;
lastPeriodGenerations: number;
lastPeriodRecords: number;
lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number;
}
export interface PaymentStats {
+162 -25
View File
@@ -9,6 +9,7 @@ from app.dependencies import get_db, get_admin_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit_record import CreditRecord
from app.models.model_config import ModelConfig
from app.models.system_config import SystemConfig
@@ -247,7 +248,10 @@ async def list_credit_records(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
user_name: str | None = Query(None),
type: str | None = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
@@ -261,9 +265,25 @@ async def list_credit_records(
if user_id:
query = query.where(CreditRecord.user_id == user_id)
count_query = count_query.where(CreditRecord.user_id == user_id)
if user_name:
query = query.where(User.username.like(f'%{user_name}%'))
count_query = count_query.join(User, CreditRecord.user_id == User.id).where(User.username.like(f'%{user_name}%'))
if type:
query = query.where(CreditRecord.type == type)
count_query = count_query.where(CreditRecord.type == type)
try:
if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.where(CreditRecord.created_at >= date_start)
count_query = count_query.where(CreditRecord.created_at >= date_start)
if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d")
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
query = query.where(CreditRecord.created_at <= date_end)
count_query = count_query.where(CreditRecord.created_at <= date_end)
except:
pass
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
@@ -1124,40 +1144,157 @@ async def list_operation_logs(
async def get_stats(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
start_date: str = Query(None),
end_date: str = Query(None),
):
total_users = (await db.execute(
select(func.count(User.id)).where(User.user_type == "frontend")
)).scalar() or 0
total_projects = (await db.execute(select(func.count(Project.id)).where(Project.deleted_at.is_(None)))).scalar() or 0
total_generations = (
await db.execute(select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None)))
).scalar() or 0
total_revenue = (
await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid"
)
)
).scalar() or 0
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
try:
if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d")
else:
date_start = today_start
if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d")
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
else:
date_end = datetime.now()
except:
date_start = today_start
date_end = datetime.now()
today_start = datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0
)
credits_today = (
await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
CreditRecord.created_at >= today_start,
)
total_users = (await db.execute(
select(func.count(User.id)).where(
User.user_type == "frontend",
User.created_at >= date_start,
User.created_at <= date_end,
)
).scalar() or 0
)).scalar() or 0
total_projects = (await db.execute(
select(func.count(Project.id)).where(
Project.deleted_at.is_(None),
Project.created_at >= date_start,
Project.created_at <= date_end,
)
)).scalar() or 0
total_generations = (await db.execute(
select(func.count(ChatGenerationTask.id)).where(
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
)
)).scalar() or 0
total_records = (await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at >= date_start,
GenerationRecord.created_at <= date_end,
)
)).scalar() or 0
total_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
)).scalar() or 0
alipay_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.payment_method == "alipay",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
wechat_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.payment_method == "wechat",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
period_duration = date_end - date_start
last_period_start = date_start - period_duration
last_period_end = date_start
last_period_users = (await db.execute(
select(func.count(User.id)).where(
User.user_type == "frontend",
User.created_at >= last_period_start,
User.created_at <= last_period_end,
)
)).scalar() or 0
last_period_projects = (await db.execute(
select(func.count(Project.id)).where(
Project.deleted_at.is_(None),
Project.created_at >= last_period_start,
Project.created_at <= last_period_end,
)
)).scalar() or 0
last_period_generations = (await db.execute(
select(func.count(ChatGenerationTask.id)).where(
ChatGenerationTask.created_at >= last_period_start,
ChatGenerationTask.created_at <= last_period_end,
)
)).scalar() or 0
last_period_records = (await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at >= last_period_start,
GenerationRecord.created_at <= last_period_end,
)
)).scalar() or 0
last_period_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.created_at >= last_period_start,
PaymentOrder.created_at <= last_period_end,
)
)).scalar() or 0
last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
CreditRecord.created_at >= last_period_start,
CreditRecord.created_at <= last_period_end,
)
)).scalar() or 0
return AdminStatsOut(
total_users=total_users,
total_projects=total_projects,
total_generations=total_generations,
total_records=total_records,
total_revenue=float(total_revenue),
credits_consumed_today=float(credits_today),
credits_consumed_today=float(credits_consumed),
today_alipay_revenue=float(alipay_revenue),
today_wechat_revenue=float(wechat_revenue),
last_period_users=last_period_users,
last_period_projects=last_period_projects,
last_period_generations=last_period_generations,
last_period_records=last_period_records,
last_period_revenue=float(last_period_revenue),
last_period_credits_consumed=float(last_period_credits_consumed),
)
+68 -5
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.schemas.sms import SmsSendRequest, SmsVerifyRequest, SmsResponse
from app.dependencies import get_db
from app.schemas.sms import SmsResponse, SmsScene, SmsSendRequest, SmsVerifyRequest
from app.services.auth import get_user_by_phone
from app.services.sms import generate_and_send_sms, verify_sms_code
router = APIRouter(prefix="/sms", tags=["短信验证码"])
@@ -10,6 +13,7 @@ router = APIRouter(prefix="/sms", tags=["短信验证码"])
def _validate_captcha_token(captcha_token: str | None) -> None:
if settings.SMS_MOCK:
return
if not (req_captcha := captcha_token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -26,15 +30,74 @@ def _validate_captcha_token(captcha_token: str | None) -> None:
)
async def _validate_sms_scene_phone(
*,
db: AsyncSession,
phone: str,
scene: SmsScene,
) -> None:
"""
按短信来源场景做手机号注册状态前置校验。
规则:
1. login:必须是已注册手机号,否则拦截发送。
2. register:必须是未注册手机号,否则拦截发送。
3. common:通用场景,暂不校验手机号注册状态。
4. set_password:保留旧场景,按通用场景处理,暂不校验手机号注册状态。
5. 其他未确认场景:理论上会被 SmsScene 枚举拦截;这里额外兜底。
"""
if scene == SmsScene.login:
user = await get_user_by_phone(db, phone)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="手机号未注册,请先注册",
)
return
if scene == SmsScene.register:
user = await get_user_by_phone(db, phone)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该手机号已注册,请直接登录",
)
return
if scene in {SmsScene.common, SmsScene.set_password}:
return
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="未确认短信场景,禁止发送验证码",
)
@router.post(
"/send",
response_model=SmsResponse,
summary="发送短信验证码",
description="客户端发送短信验证码。scene=register 用于注册,scene=login 用于短信登录,scene=set_password 用于设置密码。正式环境会按配置校验图形验证码。",
description=(
"客户端发送短信验证码。"
"scene=register 用于注册,发送前会校验手机号未注册;"
"scene=login 用于短信登录,发送前会校验手机号已注册;"
"scene=common 用于通用短信场景,暂不校验手机号注册状态;"
"scene=set_password 用于设置密码,保留旧场景,暂按通用场景处理。"
"正式环境会按配置校验图形验证码。"
),
)
async def send_sms_code(req: SmsSendRequest):
async def send_sms_code(
req: SmsSendRequest,
db: AsyncSession = Depends(get_db),
):
_validate_captcha_token(req.captcha_token)
await _validate_sms_scene_phone(
db=db,
phone=req.phone,
scene=req.scene,
)
try:
ok, code = await generate_and_send_sms(req.phone, req.scene.value)
except ValueError as exc:
@@ -66,4 +129,4 @@ async def verify_sms(req: SmsVerifyRequest):
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期",
)
return SmsResponse(message="验证成功", success=True)
return SmsResponse(message="验证成功", success=True)
+70 -28
View File
@@ -322,13 +322,54 @@ async def _seed_data():
# Seed menu configs
from app.models.menu_config import MenuConfig
default_menus = [
("/projects", "我的项目", "HomeOutlined", 0, "frontend"),
("/conversation", "AI创作", "StarOutlined", 1, "frontend"),
("/records", "项目记录", "PlayCircleOutlined", 2, "frontend"),
("/generated", "素材云", "StarOutlined", 3, "frontend"),
frontend_groups = [
("AI项目行业生成", "HomeOutlined", 0),
("AI对话生成", "HomeOutlined", 1),
("AI视频创作", "HomeOutlined", 2),
("资产管理", "HomeOutlined", 3),
("媒体关联", "HomeOutlined", 4),
]
for path, label, icon, order, target in default_menus:
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,
)
)
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)
)
@@ -341,30 +382,33 @@ async def _seed_data():
icon=icon,
sort_order=order,
is_active=True,
parent_id=frontend_group_ids.get(group_label),
menu_type="page",
menu_target=target,
is_default=True,
menu_target="frontend",
is_default=is_default,
)
)
# Seed admin group menus first, then page menus with parent_id
admin_groups = [
("模型设置", "RobotOutlined", 98),
("模型配置", "RobotOutlined", 6),
("系统设置", "SettingOutlined", 99),
]
group_ids = {}
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)
).limit(1)
)
group = existing.scalar_one_or_none()
if not group:
if group:
admin_group_ids[label] = group.id
else:
gid = generate_id()
admin_group_ids[label] = gid
db.add(
MenuConfig(
id=gid,
@@ -377,35 +421,33 @@ async def _seed_data():
menu_target="admin",
)
)
group_ids[label] = gid
else:
group_ids[label] = group.id
# (path, label, icon, sort_order, parent_group_label or None)
admin_menus = [
admin_pages = [
("/", "数据概览", "DashboardOutlined", 0, None),
("/users", "用户管理", "UserOutlined", 1, None),
("/credit-records", "交易流水", "WalletOutlined", 2, None),
("/generation-records", "生成记录", "VideoCameraOutlined", 3, None),
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
("/notifications", "消息推送", "BellOutlined", 5, None),
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型配置"),
("/models", "模型配置", "RobotOutlined", 1, "模型"),
("/image-engines", "图片模型", "PictureOutlined", 2, "模型"),
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型"),
("/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", "操作日志", "HistoryOutlined", 5, "系统设置"),
("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"),
("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"),
]
for path, label, icon, order, parent_group in admin_menus:
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)
).limit(1)
)
if not existing.scalar_one_or_none():
db.add(
@@ -418,7 +460,7 @@ async def _seed_data():
is_active=True,
menu_type="page",
menu_target="admin",
parent_id=group_ids.get(parent_group),
parent_id=admin_group_ids.get(parent_group),
)
)
+9
View File
@@ -93,5 +93,14 @@ class AdminStatsOut(BaseModel):
total_users: int
total_projects: int
total_generations: int
total_records: int
total_revenue: float
credits_consumed_today: float
today_alipay_revenue: float
today_wechat_revenue: float
last_period_users: int = 0
last_period_projects: int = 0
last_period_generations: int = 0
last_period_records: int = 0
last_period_revenue: float = 0.0
last_period_credits_consumed: float = 0.0
+22 -3
View File
@@ -6,21 +6,40 @@ from pydantic import BaseModel, Field
class SmsScene(str, Enum):
register = "register"
login = "login"
common = "common"
set_password = "set_password"
class SmsSendRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
scene: SmsScene = Field(..., description="短信场景:register=注册,login=短信登录,set_password=设置密码")
scene: SmsScene = Field(
...,
description=(
"短信场景:"
"register=注册,"
"login=短信登录,"
"common=通用场景,"
"set_password=设置密码"
),
)
captcha_token: str | None = Field(None, description="图形验证码令牌,正式环境按配置要求传入")
class SmsVerifyRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号")
scene: SmsScene = Field(..., description="短信场景")
scene: SmsScene = Field(
...,
description=(
"短信场景:"
"register=注册,"
"login=短信登录,"
"common=通用场景,"
"set_password=设置密码"
),
)
code: str = Field(..., min_length=4, max_length=8, description="验证码")
class SmsResponse(BaseModel):
message: str
success: bool
success: bool
+7 -22
View File
@@ -1,24 +1,9 @@
"""Celery task module imports.
"""Celery task package.
Celery autodiscover imports ``app.tasks``; importing task modules here ensures
custom named tasks are registered when workers start.
"""
任务模块注册统一由 app.tasks.celery_app.CELERY_TASK_IMPORTS 控制。
import logging
logger = logging.getLogger("video_gen")
try:
from app.tasks import ( # noqa: F401
generation_create_tasks,
generation_poll_tasks,
generation_download_tasks,
generation_recovery_tasks,
hot_opening_replicate_tasks,
shot_replicate_tasks,
shot_replicate_flow_tasks,
module_async_recovery_tasks,
)
except Exception:
logger.exception("Celery 任务模块导入失败,worker 可能出现 unregistered task。")
raise
这里不要主动 import 子任务模块,避免以下问题:
1. Celery worker 启动时循环导入;
2. 新增任务文件后部分 worker 注册不完整;
3. app.tasks.__init__ 被普通业务代码 import 时意外加载全部 Celery 任务。
"""
+20 -3
View File
@@ -10,6 +10,22 @@ from app.tasks.async_runner import close_loop, run_async
logger = logging.getLogger("video_gen")
# 显式注册所有 Celery 任务模块,避免新增任务文件后 worker 启动时未注册任务。
# 不再依赖 app.tasks.__init__ 内部 import,也不再依赖 autodiscover_tasks。
CELERY_TASK_IMPORTS = (
"app.tasks.generation_create_tasks",
"app.tasks.generation_poll_tasks",
"app.tasks.generation_download_tasks",
"app.tasks.generation_recovery_tasks",
"app.tasks.hot_opening_replicate_tasks",
"app.tasks.shot_replicate_tasks",
"app.tasks.shot_replicate_flow_tasks",
"app.tasks.module_async_recovery_tasks",
"app.tasks.user_oauth_tasks",
"app.tasks.cleanup",
)
def _derive_redis_db(url: str, db_no: int) -> str:
if not url:
return url
@@ -24,10 +40,11 @@ broker_url = settings.CELERY_BROKER_URL or (_derive_redis_db(settings.REDIS_URL,
backend_url = settings.CELERY_RESULT_BACKEND or (_derive_redis_db(settings.REDIS_URL, 2) if settings.REDIS_URL else "")
if broker_url:
celery_app = Celery("videogen")
celery_app = Celery("videogen", include=CELERY_TASK_IMPORTS)
celery_app.conf.update(
broker_url=broker_url,
result_backend=backend_url or broker_url,
imports=CELERY_TASK_IMPORTS,
task_serializer="json",
accept_content=["json"],
result_serializer="json",
@@ -60,10 +77,10 @@ if broker_url:
"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"},
"user_oauth.update_oauth_accounts": {"queue": "default"},
"app.tasks.cleanup.*": {"queue": "default"},
},
)
celery_app.autodiscover_tasks(["app.tasks"])
else:
celery_app = None
@@ -164,4 +181,4 @@ def on_worker_process_shutdown(**kwargs):
except Exception:
pass
finally:
close_loop()
close_loop()
Binary file not shown.
+1
View File
@@ -100,6 +100,7 @@ const App = () => {
<Route path="credits" element={<CreditsPage />} />
<Route path="credit-records" element={<UserCenterPage />} />
<Route path="order-records" element={<UserCenterPage />} />
<Route path="user-center" element={<UserCenterPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="conversation" element={<GenerateConver />} />
<Route path="initial" element={<InitialReplication />} />
+32
View File
@@ -342,3 +342,35 @@ export async function getAuthorizationList(params: OAuthAppParam): Promise<OAuth
return api.get<OAuthAppList>(`/admin/user-oauth-apps/list?${query.toString()}`);
}
// 爆款开头复刻
export async function generateReplication(params: any): Promise<any> {
return api.post('/hot-opening-replications/tasks', params);
}
// 获取爆款开头复刻任务列表
export async function getReplicationList(page: number,page_size: number): Promise<any[]> {
return api.get(`/hot-opening-replications/tasks?page=${page}&page_size=${page_size}`);
}
// 获取爆款开头复刻任务详情
export async function getReplicationDetail(id: string): Promise<any> {
return api.get(`/hot-opening-replications/tasks/${id}`);
}
// 第一步,生成提示词
export async function getone(projectId: string, stepId: string): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image-prompt`);
}
// 第二步,生成图片
export async function gettwo(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params);
}
// 第三步,生成视频提示词
export async function getthree(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video-prompt`, params);
}
// 第四步,生成视频
export async function getfour(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video`, params);
}
@@ -281,8 +281,8 @@ const AppLayout: React.FC = () => {
else if (key === 'changePwd') { setPwdModalOpen(true); }
else if (key === 'messages') { navigate('/messages'); }
else if (key === 'recharge') { setRechargeModalOpen(true); }
else if (key === 'myCredits') { navigate('/credit-records'); }
else if (key === 'orderRecords') { navigate('/order-records'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
};
const handleChangePwd = async () => {
@@ -410,11 +410,11 @@ const AppLayout: React.FC = () => {
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{!collapsed && (
{/* {!collapsed && (
<div style={{ color: 'rgba(148,163,184,0.4)', 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');
@@ -454,19 +454,15 @@ const AppLayout: React.FC = () => {
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));
if (!collapsed) {
const isGroupOpen = !collapsedGroups[g.id];
items.push(
<div key={g.id}>
<div onClick={() => setCollapsedGroups(prev => ({ ...prev, [g.id]: !prev[g.id] }))} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 14px', margin: '8px 0 2px', cursor: 'pointer',
color: 'rgba(148,163,184,0.5)', fontSize: 11, fontWeight: 600,
letterSpacing: 0.5,
<div style={{
color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600,
padding: '8px 12px 6px', letterSpacing: 1,
}}>
<span>{g.label}</span>
<span style={{ fontSize: 10, transform: isGroupOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}></span>
{g.label}
</div>
{isGroupOpen && children.map(c => renderMenuItem(c, 1))}
{children.map(c => renderMenuItem(c, 1))}
</div>
);
} else {
+56 -28
View File
@@ -121,6 +121,9 @@ const AIChatPage: React.FC = () => {
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = useRef<HTMLVideoElement>(null);
// 提示词展开状态
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
// 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => {
if (!url) return null;
@@ -249,7 +252,6 @@ const AIChatPage: React.FC = () => {
}
console.log(config);
// 根据配置计算积分
@@ -456,7 +458,7 @@ const AIChatPage: React.FC = () => {
setTotalnumber(data.total);
}).catch((error) => {
});
}, 5000);
}, 10000);
// 清理定时器
return () => {
@@ -807,7 +809,7 @@ const AIChatPage: React.FC = () => {
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
@@ -818,7 +820,7 @@ const AIChatPage: React.FC = () => {
e.stopPropagation();
if (!previewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`;
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
@@ -1088,7 +1090,7 @@ const AIChatPage: React.FC = () => {
}}
>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8 }}>
<div style={{ position: 'absolute', top: 8, right: 8 ,zIndex: 100}}>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
@@ -1099,7 +1101,6 @@ const AIChatPage: React.FC = () => {
setGen_list(prev => prev.filter(item => item.id !== msg.id));
setTotalnumber(prev => prev - 1);
} catch (error: any) {
console.log(error);
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
msgApi.error(errorMsg);
@@ -1115,8 +1116,8 @@ const AIChatPage: React.FC = () => {
height: 28,
borderRadius: 8,
border: 'none',
background: 'rgba(0,0,0,0.05)',
color: '#999',
background: 'rgba(146, 144, 144, 1)',
color: '#ffffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -1140,29 +1141,56 @@ const AIChatPage: React.FC = () => {
</div>
{/* 文本内容 */}
<Tooltip
title={msg.originalPrompt}
placement="top"
style={{ maxWidth: '400px' }}
>
<p style={{
margin: '8px 0',
fontSize: 14,
<div
style={{
position: 'relative',
margin: '8px 0',
padding: '12px 16px',
backgroundColor: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
}}
onMouseEnter={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.add(msg.id);
return newSet;
});
}}
onMouseLeave={() => {
setExpandedPrompts(prev => {
const newSet = new Set(prev);
newSet.delete(msg.id);
return newSet;
});
}}
>
{/* 默认显示:一行省略 */}
<div style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 4,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
{msg.originalPrompt}
</p>
</Tooltip>
display: expandedPrompts.has(msg.id) ? 'none' : 'block',
}}>
{msg.originalPrompt}
</div>
{/* 鼠标移入显示:完整内容 */}
<div style={{
maxHeight: 200,
overflowY: 'auto',
display: expandedPrompts.has(msg.id) ? 'block' : 'none',
wordBreak: 'break-word',
}}>
{msg.originalPrompt}
</div>
</div>
{/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */}
@@ -2354,7 +2382,7 @@ const AIChatPage: React.FC = () => {
<Button key="download" type="primary" onClick={() => {
if (!attachmentPreviewUrl) return;
const link = document.createElement('a');
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`;
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
document.body.appendChild(link);
link.click();
+1 -1
View File
@@ -4323,7 +4323,7 @@ const GeneratePage: React.FC = () => {
icon={<DownloadOutlined />}
onClick={() => {
const a = document.createElement("a");
a.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${type === "video" ? record.videoUrl : record.imageUrl}`;
a.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${type === "video" ? record.videoUrl : record.imageUrl}&download=1`;
a.download = `${record.projectName}${type === "video" ? ".mp4" : ".png"}`;
a.click();
}}
+1 -1
View File
@@ -1507,7 +1507,7 @@ const GeneratedRecord: React.FC = () => {
if (videoRef.current) {
videoRef.current.pause();
}
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}`;
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
window.open(url, '_blank');
}}
style={{ flex: 1, borderRadius: 8 }}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -401,7 +401,7 @@ const RecordsPage: React.FC = () => {
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
<Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}>
<Button size="small" icon={<DownloadOutlined />}
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${type === 'video' ? record.videoUrl : record.imageUrl}`; a.download = `${record.projectName}${type === 'video' ? '.mp4' : '.png'}`; a.click(); }}
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${type === 'video' ? record.videoUrl : record.imageUrl}&download=1`; a.download = `${record.projectName}${type === 'video' ? '.mp4' : '.png'}`; a.click(); }}
style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
</Button>
+221 -536
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
@@ -9,40 +9,12 @@ export default function VideoFrameExtractor() {
const [videoUrl, setVideoUrl] = useState<string>('');
const [error, setError] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [productName, setProductName] = useState<string>('');
const [videoDuration, setVideoDuration] = useState<number>(0);
const [segmentStart, setSegmentStart] = useState<number>(0);
const [segmentEnd, setSegmentEnd] = useState<number>(0);
const [isSegmentSelected, setIsSegmentSelected] = useState<boolean>(false);
const [dragging, setDragging] = useState<'start' | 'end' | null>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [framePreviews, setFramePreviews] = useState<string[]>([]);
const [tableData] = useState<any[]>([
{
id: 1,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
originalName: '进圈',
productName: '他趣',
status: '视频成功',
createTime: '2026-05-14 17:49:20',
},
{
id: 2,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
originalName: '香水',
productName: '面霜',
status: '视频提示词成功',
createTime: '2026-05-08 08:57:46',
},
]);
const videoRef = useRef<HTMLVideoElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const hiddenVideoRef = useRef<HTMLVideoElement>(null);
const cleanupResources = useCallback(() => {
if (videoUrl) {
@@ -51,12 +23,7 @@ export default function VideoFrameExtractor() {
setVideoUrl('');
setError('');
setVideoDuration(0);
setSegmentStart(0);
setSegmentEnd(0);
setIsSegmentSelected(false);
setIsPlaying(false);
setCurrentTime(0);
setFramePreviews([]);
setProductName('');
}, [videoUrl]);
const handleFileChange = (file: File) => {
@@ -78,213 +45,104 @@ export default function VideoFrameExtractor() {
return false;
};
const generateFramePreviews = useCallback(async () => {
if (!canvasRef.current || videoDuration <= 0 || !videoUrl) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 创建隐藏的视频元素用于生成帧预览
if (!hiddenVideoRef.current) {
hiddenVideoRef.current = document.createElement('video');
hiddenVideoRef.current.style.display = 'none';
hiddenVideoRef.current.crossOrigin = 'anonymous';
document.body.appendChild(hiddenVideoRef.current);
}
const hiddenVideo = hiddenVideoRef.current;
hiddenVideo.src = videoUrl;
await new Promise<void>((resolve) => {
hiddenVideo.addEventListener('loadedmetadata', () => resolve(), { once: true });
});
// 按秒数生成帧,每秒 1 帧,最多 15 帧
const numFrames = Math.min(Math.ceil(videoDuration), 15);
const previews: string[] = [];
for (let i = 0; i < numFrames; i++) {
const time = ((i + 0.5) / numFrames) * videoDuration;
await new Promise<void>((resolve) => {
const handleSeeked = () => {
hiddenVideo.removeEventListener('seeked', handleSeeked);
setTimeout(resolve, 80);
};
hiddenVideo.addEventListener('seeked', handleSeeked);
hiddenVideo.currentTime = time;
});
canvas.width = 120;
canvas.height = 80;
ctx.drawImage(hiddenVideo, 0, 0, canvas.width, canvas.height);
previews.push(canvas.toDataURL('image/jpeg', 0.8));
}
setFramePreviews(previews);
}, [videoDuration, videoUrl]);
const handleVideoLoaded = useCallback(() => {
if (videoRef.current) {
const duration = videoRef.current.duration;
setVideoDuration(duration);
setSegmentStart(0);
setSegmentEnd(Math.min(15, duration));
setIsSegmentSelected(true);
setVideoDuration(videoRef.current.duration);
}
}, []);
useEffect(() => {
if (videoDuration > 0) {
generateFramePreviews();
}
}, [videoDuration, generateFramePreviews]);
const handleVideoTimeUpdate = useCallback(() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
}, []);
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play();
setIsPlaying(true);
} else {
videoRef.current.pause();
setIsPlaying(false);
}
}, []);
const formatTime = useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const cs = Math.floor((seconds % 1) * 100);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}:${String(cs).padStart(2, '0')}`;
}, []);
const formatTimeShort = useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}, []);
const getTimeFromEvent = useCallback((clientX: number): number | null => {
if (!timelineRef.current || videoDuration === 0) return null;
const rect = timelineRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
const percentage = x / rect.width;
return percentage * videoDuration;
}, [videoDuration]);
const handleTimelineMouseDown = useCallback((
e: React.MouseEvent<HTMLDivElement>,
handle: 'start' | 'end'
) => {
e.preventDefault();
e.stopPropagation();
setDragging(handle);
}, []);
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const time = getTimeFromEvent(e.clientX);
if (time !== null && videoRef.current) {
videoRef.current.currentTime = time;
}
}, [getTimeFromEvent]);
useEffect(() => {
if (!dragging) return;
const handleMouseMove = (e: MouseEvent) => {
const time = getTimeFromEvent(e.clientX);
if (time === null) return;
if (dragging === 'start') {
setSegmentStart(Math.max(0, Math.min(time, segmentEnd - 0.3)));
} else {
setSegmentEnd(Math.max(segmentStart + 0.3, Math.min(time, videoDuration)));
}
};
const handleMouseUp = () => {
setDragging(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, segmentStart, segmentEnd, videoDuration, getTimeFromEvent]);
const segmentDuration = useMemo(() => segmentEnd - segmentStart, [segmentEnd, segmentStart]);
const segmentValid = useMemo(() => segmentDuration >= 4 && segmentDuration <= 15, [segmentDuration]);
const startPercent = useMemo(() => videoDuration > 0 ? (segmentStart / videoDuration) * 100 : 0, [segmentStart, videoDuration]);
const endPercent = useMemo(() => videoDuration > 0 ? (segmentEnd / videoDuration) * 100 : 100, [segmentEnd, videoDuration]);
const rangeWidth = useMemo(() => endPercent - startPercent, [endPercent, startPercent]);
const tableData = [
{
id: 1,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
originalName: '进圈',
productName: '他趣',
status: '视频成功',
createTime: '2026-05-14 17:49:20',
},
{
id: 2,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
originalName: '香水',
productName: '面霜',
status: '视频提示词成功',
createTime: '2026-05-08 08:57:46',
},
];
return (
<div style={{
height: '94vh',
background: '#ffffff',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
padding: '20px',
boxSizing: 'border-box',
overflow: 'auto'
}}>
<div style={{ textAlign: 'right', marginBottom: 20 }}>
<Button
type="text"
icon={<FileTextOutlined />}
style={{
borderRadius: 6,
fontSize: 16,
fontWeight: 600,
color: '#656efa'
}}
onClick={() => setIsModalOpen(true)}
>
</Button>
</div>
<div style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%)', padding: '20px' }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div>
<div style={{
width: 36,
height: 36,
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 18,
fontWeight: 600
}}>
</div>
</div>
<div>
<h1 style={{
fontSize: 22,
fontWeight: 600,
margin: 0,
color: '#1e293b'
}}>
</h1>
<p style={{ fontSize: 13, color: '#94a3b8', margin: 4 }}>
仿
</p>
</div>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 'calc(100vh - 100px)'
}}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
marginBottom: 8
}}>
<button
onClick={() => setIsModalOpen(true)}
style={{
padding: '10px 20px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
color: '#6366f1',
display: 'flex',
alignItems: 'center',
gap: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
}}
>
<FileTextOutlined />
<span></span>
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 32 }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 36,
height: 36,
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
borderRadius: 10,
width: 56,
height: 56,
background: 'linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%)',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 20
margin: '0 auto 16px'
}}>
<span style={{ fontSize: 28 }}>🎬</span>
</div>
<h1 style={{
fontSize: 26,
<h2 style={{
fontSize: 22,
fontWeight: 600,
margin: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
@@ -292,15 +150,15 @@ export default function VideoFrameExtractor() {
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}>
</h1>
</h2>
<p style={{ fontSize: 14, color: '#888', margin: '8px 0 0 0' }}>
MP4MOV 100MB
</p>
</div>
<p style={{ fontSize: 14, color: '#888', margin: 0 }}>
仿
</p>
</div>
<div style={{ width: '100%', maxWidth: 720 }}>
<div style={{ width: '100%', maxWidth: 720, margin: '0 auto' }}>
{error && (
<div style={{
backgroundColor: '#fff5f5',
@@ -325,7 +183,7 @@ export default function VideoFrameExtractor() {
background: '#faf5ff',
border: '2px dashed #c4b5fd',
borderRadius: 24,
padding: '40px 20px'
padding: '60px 20px'
}}
>
<div style={{
@@ -344,7 +202,7 @@ export default function VideoFrameExtractor() {
</p>
<p style={{ fontSize: 13, color: '#999', margin: 0 }}>
MP4MOV 100MB 3 4-15
MP4MOV 100MB 3
</p>
</Upload.Dragger>
)}
@@ -397,322 +255,149 @@ export default function VideoFrameExtractor() {
<video
ref={videoRef}
src={videoUrl}
controls
onLoadedMetadata={handleVideoLoaded}
onTimeUpdate={handleVideoTimeUpdate}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onClick={togglePlay}
style={{
maxWidth: '100%',
maxHeight: 320,
borderRadius: 12,
background: '#000',
cursor: 'pointer'
background: '#000'
}}
/>
</div>
{isSegmentSelected && (
<div style={{
background: '#f8fafc',
borderRadius: 16,
padding: '20px'
{/* 产品名称输入框 */}
<div style={{ marginBottom: 24 }}>
<label style={{
display: 'block',
fontSize: 14,
fontWeight: 500,
color: '#374151',
marginBottom: 8
}}>
<div style={{
display: 'flex',
</label>
<Input
placeholder="请输入产品名称"
value={productName}
onChange={(e) => setProductName(e.target.value)}
style={{
width: '100%',
height: 40,
borderRadius: 8,
borderColor: '#e5e7eb'
}}
/>
</div>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button
disabled={!videoUrl || !productName.trim()}
style={{
padding: '14px 56px',
background: videoUrl && productName.trim()
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
: '#e2e8f0',
color: 'white',
border: 'none',
borderRadius: 14,
fontSize: 15,
fontWeight: 600,
cursor: videoUrl && productName.trim() ? 'pointer' : 'not-allowed',
boxShadow: videoUrl && productName.trim() ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
marginBottom: 16
gap: 10
}}
>
<span></span>
<span style={{
fontSize: 12,
opacity: 0.85,
fontWeight: 400
}}>
<button
onClick={togglePlay}
style={{
width: 32,
height: 32,
border: 'none',
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
color: 'white',
borderRadius: 8
}}
>
{isPlaying ? '⏸' : '▶'}
</button>
<span style={{
fontSize: 14,
color: '#333',
fontFamily: 'monospace',
fontWeight: 500
}}>
{formatTime(currentTime)} / {formatTime(videoDuration)}
</span>
</div>
<div
ref={timelineRef}
style={{
position: 'relative',
height: 70,
background: '#fff',
borderRadius: 12,
cursor: dragging ? 'grabbing' : 'pointer',
// overflow: 'visible',
marginBottom: 12,
userSelect: 'none',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
border: '1px solid #e2e8f0',
// overflow: 'auto',
}}
onClick={handleTimelineClick}
>
<div
style={{
position: 'absolute',
top: 4,
bottom: 4,
left: `calc(${startPercent}% + 0px)`,
width: `calc(${rangeWidth}% - 0px)`,
background: 'linear-gradient(90deg, rgba(99, 102, 241, 0.25) 0%, rgba(236, 72, 153, 0.25) 100%)',
borderRadius: 8,
pointerEvents: 'none'
}}
/>
<div style={{
position: 'absolute',
top: 8,
bottom: 8,
left: 0,
right: 0,
display: 'flex',
// justifyContent: 'space-around',
pointerEvents: 'none',
// overflow: 'auto',
}}>
{framePreviews.map((frame, index) => (
<div
key={index}
style={{
width: `calc((100% - 16px) / ${framePreviews.length})`,
// width: 200,
height: '100%',
objectFit: 'cover',
borderRadius: 4,
background: '#f1f5f9',
overflow: 'hidden',
marginRight: 8,
}}
>
{frame && (
<img
src={frame}
alt={`frame ${index}`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
)}
</div>
))}
</div>
<div
onMouseDown={(e) => handleTimelineMouseDown(e, 'start')}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `calc(${startPercent}% - 10px)`,
width: 20,
background: 'linear-gradient(180deg, #6366f1 0%, #4f46e5 100%)',
borderRadius: 6,
cursor: 'ew-resize',
boxShadow: dragging === 'start'
? '0 4px 16px rgba(99, 102, 241, 0.4)'
: '0 2px 8px rgba(99, 102, 241, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 2
}}>
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
</div>
</div>
<div
onMouseDown={(e) => handleTimelineMouseDown(e, 'end')}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `calc(${endPercent}% - 10px)`,
width: 20,
background: 'linear-gradient(180deg, #ec4899 0%, #db2777 100%)',
borderRadius: 6,
cursor: 'ew-resize',
boxShadow: dragging === 'end'
? '0 4px 16px rgba(236, 72, 153, 0.4)'
: '0 2px 8px rgba(236, 72, 153, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 2
}}>
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
</div>
</div>
</div>
<div style={{
textAlign: 'center',
fontSize: 14,
color: '#666'
}}>
{formatTimeShort(segmentDuration)}
{!segmentValid && (
<span style={{ color: '#dc2626', marginLeft: 8 }}>
( 4-15 )
</span>
)}
</div>
</div>
)}
10
</span>
</button>
</div>
</div>
</div>
)}
<div style={{ textAlign: 'center', marginTop: 32 }}>
<button
disabled={!videoUrl || !segmentValid}
style={{
padding: '14px 56px',
background: videoUrl && segmentValid
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
: '#e2e8f0',
color: 'white',
border: 'none',
borderRadius: 14,
fontSize: 15,
fontWeight: 600,
cursor: videoUrl && segmentValid ? 'pointer' : 'not-allowed',
boxShadow: videoUrl && segmentValid ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 10
}}
>
<span></span>
<span style={{
fontSize: 12,
opacity: 0.85,
fontWeight: 400
}}>
10
</span>
</button>
</div>
<canvas ref={canvasRef} style={{ display: 'none' }} />
<Modal
title="创作记录"
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={800}
footer={null}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Input
placeholder="搜索产品名称"
style={{ width: 200, borderRadius: 6, marginRight: 8 }}
/>
<Button type="primary" style={{ borderRadius: 6 }}>
</Button>
</div>
<Table
columns={[
{
title: '产品图片',
dataIndex: 'image',
key: 'image',
width: 90,
render: (image: string) => (
<img
src={image}
alt="产品图片"
style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
/>
),
},
{
title: '原产品名称',
dataIndex: 'originalName',
key: 'originalName',
},
{
title: '产品名称',
dataIndex: 'productName',
key: 'productName',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<button
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }}
>
</button>
),
},
]}
dataSource={tableData}
rowKey="id"
pagination={false}
/>
</Modal>
</div>
</div>
<canvas ref={canvasRef} style={{ display: 'none' }} />
<Modal
title="创作记录"
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={800}
footer={null}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Input
placeholder="搜索产品名称"
style={{ width: 200, borderRadius: 6, marginRight: 8 }}
/>
<Button type="primary" style={{ borderRadius: 6 }}>
</Button>
</div>
<Table
columns={[
{
title: '产品图片',
dataIndex: 'image',
key: 'image',
width: 90,
render: (image: string) => (
<img
src={image}
alt="产品图片"
style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
/>
),
},
{
title: '原产品名称',
dataIndex: 'originalName',
key: 'originalName',
},
{
title: '产品名称',
dataIndex: 'productName',
key: 'productName',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<button
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }}
>
</button>
),
},
]}
dataSource={tableData}
rowKey="id"
pagination={false}
/>
</Modal>
</div>
);
}
}
+12 -1
View File
@@ -1,11 +1,22 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Tabs } from 'antd';
import { useSearchParams } from 'react-router-dom';
import CreditRecordsPage from './CreditRecordsPage';
import OrderRecordsPage from './OrderRecordsPage';
const UserCenterPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('credits');
useEffect(() => {
const tab = searchParams.get('tab') || 'credits';
if (tab === 'orders') {
setActiveTab('orders');
} else {
setActiveTab('credits');
}
}, [searchParams]);
const tabs = [
{
key: 'credits',
+10 -2
View File
@@ -13,6 +13,14 @@
width: 100%;
height: 100%;
}
.product_img{
margin-left: 20px;
/* 加载旋转动画 */
@keyframes spinSlow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}