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> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title> <title>VideoGen.AI 管理后台</title>
<script type="module" crossorigin src="/assets/index-CIsX_bex.js"></script> <script type="module" crossorigin src="/assets/index-C93H_elA.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
+16 -3
View File
@@ -86,8 +86,12 @@ export async function markNotificationRead(id: string): Promise<void> {
// ── Admin ───────────────────────────────────────────────── // ── Admin ─────────────────────────────────────────────────
export async function getAdminStats(): Promise<AdminStats> { export async function getAdminStats(startDate?: string, endDate?: string): Promise<AdminStats> {
return api.get('/admin/stats'); 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[]> { 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(); 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(); const params = new URLSearchParams();
if (filters?.user_id) params.set('user_id', filters.user_id); 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?.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}` : ''; const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`); return api.get(`/admin/credit-records${q}`);
} }
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Select, Space, Table, Tag, Typography, message, Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
} from 'antd'; } from 'antd';
import { import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getCreditRecords } from '../api'; import { getCreditRecords } from '../api';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import dayjs from 'dayjs';
interface CreditRecord { interface CreditRecord {
id: string; id: string;
@@ -29,11 +30,18 @@ const AdminCreditRecords: React.FC = () => {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string>(''); 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); setLoading(true);
try { 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 || []); setRecords(res.items || []);
setTotal(res.total || 0); setTotal(res.total || 0);
} catch { } catch {
@@ -45,9 +53,15 @@ const AdminCreditRecords: React.FC = () => {
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const handleTypeFilter = (value: string) => { const handleSearch = () => {
setTypeFilter(value); load();
load(value || undefined); };
const handleReset = () => {
setTypeFilter('');
setUserNameFilter('');
setDateRange([null, null]);
load();
}; };
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0); const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
@@ -139,11 +153,11 @@ const AdminCreditRecords: React.FC = () => {
</div> </div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space> <Space wrap>
<Select <Select
value={typeFilter} value={typeFilter}
onChange={handleTypeFilter} onChange={(value) => setTypeFilter(value)}
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={[
{ value: '', label: '全部类型' }, { value: '', label: '全部类型' },
@@ -152,8 +166,25 @@ const AdminCreditRecords: React.FC = () => {
{ value: 'refund', label: '退回' }, { 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> </Space>
<Button icon={<ReloadOutlined />} onClick={() => load(typeFilter || undefined)}></Button>
</div> </div>
<Table <Table
columns={columns} columns={columns}
+389 -66
View File
@@ -1,90 +1,413 @@
import React, { useEffect, useState } from 'react'; 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 { import {
UserOutlined, UserOutlined,
ProjectOutlined, ProjectOutlined,
PlayCircleOutlined, PlayCircleOutlined,
FileTextOutlined,
DollarOutlined, DollarOutlined,
ThunderboltOutlined, WalletOutlined,
ArrowUpOutlined, ArrowUpOutlined,
CalendarOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getAdminStats } from '../api'; import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats } from '../types'; import type { AdminStats, SystemConfig } from '../types';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const AdminDashboard: React.FC = () => { const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null); const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
const load = async () => {
setLoading(true);
try {
const data = await getAdminStats();
setStats(data);
} catch { /* auth error handled by client */ }
setLoading(false);
};
load(); load();
loadSiteName();
}, []); }, []);
const statCards = stats ? [ const handleToday = () => {
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' }, const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' }, setStartDate(dates);
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' }, loadWithDates(dates);
{ 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 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 ( const financeStats = stats ? [
<div> {
{/* Stats Cards */} title: '支付宝收入',
<Row gutter={[16, 16]}> value: stats.todayAlipayRevenue,
{statCards.map((s, i) => ( icon: <WalletOutlined />,
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}> gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
<Card bordered={false} loading={loading} prefix: '¥',
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> suffix: '',
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> description: '支付宝收款',
<div style={{ tag: '支付宝'
width: 44, height: 44, borderRadius: 10, },
background: s.bg, display: 'flex', {
alignItems: 'center', justifyContent: 'center', title: '微信收入',
fontSize: 20, color: s.color, flexShrink: 0, value: stats.todayWechatRevenue,
}}> icon: <DollarOutlined />,
{s.icon} gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
</div> prefix: '¥',
<div> suffix: '',
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div> description: '微信收款',
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}> tag: '微信支付'
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value} },
</div> {
</div> title: '总收入',
</div> value: stats.totalRevenue,
</Card> lastPeriodValue: stats.lastPeriodRevenue,
</Col> icon: <ArrowUpOutlined />,
))} gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
</Row> 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 */} const StatCard: React.FC<{
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> title: string;
<Col xs={24} lg={12}> value: number;
<Card title="系统信息" bordered={false} lastPeriodValue?: number;
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> icon: React.ReactNode;
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> gradient: string;
{[ prefix?: string;
{ label: '平台名称', value: 'VideoGen.AI' }, suffix?: string;
{ label: 'API版本', value: 'v1.0.0' }, description?: string;
{ label: '数据库', value: 'PostgreSQL' }, tag?: string;
{ label: '视频引擎', value: 'Seedance 2.0' }, }> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
].map(item => ( const change = lastPeriodValue !== undefined && lastPeriodValue > 0
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}> ? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
<Typography.Text type="secondary">{item.label}</Typography.Text> : null;
<Typography.Text strong>{item.value}</Typography.Text> const isPositive = change !== null && parseFloat(change) >= 0;
</div>
))} 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> </div>
</Card> <div style={{
</Col> fontSize: 28,
</Row> 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> </div>
); );
}; };
@@ -763,81 +763,76 @@ const AdminGenerationAiRecords: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <div>
<Card <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
title={( <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space> <Space>
<FileImageOutlined /> <PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<span></span> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space> <Tag color="purple">{total} </Tag>
)} </Space>
extra={( <Space>
<Space wrap> <Select
<Select allowClear
allowClear placeholder="状态筛选"
placeholder="状态筛选" value={filterStatus || undefined}
value={filterStatus || undefined} style={{ width: 140 }}
style={{ width: 140 }} onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
onChange={(v) => { options={[
setFilterStatus(v || ''); { value: 'generating', label: '生成中' },
setPage(1); { value: 'completed', label: '已完成' },
}} { value: 'failed', label: '失败' },
options={[ ]}
{ value: 'generating', label: '生成中' }, />
{ value: 'completed', label: '已完成' }, <Select
{ value: 'failed', label: '失败' }, allowClear
]} placeholder="类型筛选"
/> value={filterGenType || undefined}
<Select style={{ width: 120 }}
allowClear onChange={(v) => { setFilterGenType(v || ''); setPage(1); }}
placeholder="类型筛选" options={[
value={filterGenType || undefined} { value: 'image', label: '图片' },
style={{ width: 120 }} { value: 'video', label: '视频' },
onChange={(v) => { ]}
setFilterGenType(v || ''); />
setPage(1); <Input
}} placeholder="用户ID搜索"
options={[ prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
{ value: 'image', label: '图片' }, style={{ width: 180 }}
{ value: 'video', label: '视频' }, value={inputUserId}
]} onChange={(e) => setInputUserId(e.target.value)}
/> onPressEnter={handleSearch}
<Input allowClear
placeholder="用户ID" />
value={inputUserId} <Input
onChange={(e) => setInputUserId(e.target.value)} placeholder="用户名搜索"
onPressEnter={handleSearch} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }} style={{ width: 160 }}
allowClear value={inputUserName}
/> onChange={(e) => setInputUserName(e.target.value)}
<Input onPressEnter={handleSearch}
placeholder="用户名" allowClear
value={inputUserName} />
onChange={(e) => setInputUserName(e.target.value)} <Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
onPressEnter={handleSearch}
style={{ width: 160 }} </Button>
allowClear </Space>
/> </div>
<Button icon={<SearchOutlined />} onClick={handleSearch}></Button>
</Space>
)}
bordered={false}
style={{ borderRadius: 16 }}
>
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
columns={columns as any} columns={columns as any}
dataSource={records} dataSource={records}
scroll={{ x: 1180 }} scroll={{ x: 1180 }}
pagination={{ pagination={{
current: page, current: page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
total, total,
showSizeChanger: false, showSizeChanger: false,
showTotal: (t) => `${t}`, showTotal: (t) => `${t}`,
onChange: (p) => setPage(p), onChange: (p) => setPage(p),
}} }}
/> />
</Card> </Card>
+9
View File
@@ -113,8 +113,17 @@ export interface AdminStats {
totalUsers: number; totalUsers: number;
totalProjects: number; totalProjects: number;
totalGenerations: number; totalGenerations: number;
totalRecords: number;
totalRevenue: number; totalRevenue: number;
creditsConsumedToday: number; creditsConsumedToday: number;
todayAlipayRevenue: number;
todayWechatRevenue: number;
lastPeriodUsers: number;
lastPeriodProjects: number;
lastPeriodGenerations: number;
lastPeriodRecords: number;
lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number;
} }
export interface PaymentStats { 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.user import User
from app.models.project import Project from app.models.project import Project
from app.models.generation_record import GenerationRecord 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.credit_record import CreditRecord
from app.models.model_config import ModelConfig from app.models.model_config import ModelConfig
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
@@ -247,7 +248,10 @@ async def list_credit_records(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None), user_id: str | None = Query(None),
user_name: str | None = Query(None),
type: 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), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -261,9 +265,25 @@ async def list_credit_records(
if user_id: if user_id:
query = query.where(CreditRecord.user_id == user_id) query = query.where(CreditRecord.user_id == user_id)
count_query = count_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: if type:
query = query.where(CreditRecord.type == type) query = query.where(CreditRecord.type == type)
count_query = count_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 total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) 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( async def get_stats(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
start_date: str = Query(None),
end_date: str = Query(None),
): ):
total_users = (await db.execute( today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
select(func.count(User.id)).where(User.user_type == "frontend")
)).scalar() or 0 try:
total_projects = (await db.execute(select(func.count(Project.id)).where(Project.deleted_at.is_(None)))).scalar() or 0 if start_date:
total_generations = ( date_start = datetime.strptime(start_date, "%Y-%m-%d")
await db.execute(select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))) else:
).scalar() or 0 date_start = today_start
total_revenue = ( if end_date:
await db.execute( date_end = datetime.strptime(end_date, "%Y-%m-%d")
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where( date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
PaymentOrder.status == "paid" else:
) date_end = datetime.now()
) except:
).scalar() or 0 date_start = today_start
date_end = datetime.now()
today_start = datetime.now().replace( total_users = (await db.execute(
hour=0, minute=0, second=0, microsecond=0 select(func.count(User.id)).where(
) User.user_type == "frontend",
credits_today = ( User.created_at >= date_start,
await db.execute( User.created_at <= date_end,
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
CreditRecord.created_at >= today_start,
)
) )
).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( return AdminStatsOut(
total_users=total_users, total_users=total_users,
total_projects=total_projects, total_projects=total_projects,
total_generations=total_generations, total_generations=total_generations,
total_records=total_records,
total_revenue=float(total_revenue), 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.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 from app.services.sms import generate_and_send_sms, verify_sms_code
router = APIRouter(prefix="/sms", tags=["短信验证码"]) router = APIRouter(prefix="/sms", tags=["短信验证码"])
@@ -10,6 +13,7 @@ router = APIRouter(prefix="/sms", tags=["短信验证码"])
def _validate_captcha_token(captcha_token: str | None) -> None: def _validate_captcha_token(captcha_token: str | None) -> None:
if settings.SMS_MOCK: if settings.SMS_MOCK:
return return
if not (req_captcha := captcha_token): if not (req_captcha := captcha_token):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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( @router.post(
"/send", "/send",
response_model=SmsResponse, response_model=SmsResponse,
summary="发送短信验证码", 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) _validate_captcha_token(req.captcha_token)
await _validate_sms_scene_phone(
db=db,
phone=req.phone,
scene=req.scene,
)
try: try:
ok, code = await generate_and_send_sms(req.phone, req.scene.value) ok, code = await generate_and_send_sms(req.phone, req.scene.value)
except ValueError as exc: except ValueError as exc:
@@ -66,4 +129,4 @@ async def verify_sms(req: SmsVerifyRequest):
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期", 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 # Seed menu configs
from app.models.menu_config import MenuConfig from app.models.menu_config import MenuConfig
default_menus = [ frontend_groups = [
("/projects", "我的项目", "HomeOutlined", 0, "frontend"), ("AI项目行业生成", "HomeOutlined", 0),
("/conversation", "AI创作", "StarOutlined", 1, "frontend"), ("AI对话生成", "HomeOutlined", 1),
("/records", "项目记录", "PlayCircleOutlined", 2, "frontend"), ("AI视频创作", "HomeOutlined", 2),
("/generated", "素材云", "StarOutlined", 3, "frontend"), ("资产管理", "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( existing = await db.execute(
select(MenuConfig).where(MenuConfig.path == path).limit(1) select(MenuConfig).where(MenuConfig.path == path).limit(1)
) )
@@ -341,30 +382,33 @@ async def _seed_data():
icon=icon, icon=icon,
sort_order=order, sort_order=order,
is_active=True, is_active=True,
parent_id=frontend_group_ids.get(group_label),
menu_type="page", menu_type="page",
menu_target=target, menu_target="frontend",
is_default=True, is_default=is_default,
) )
) )
# Seed admin group menus first, then page menus with parent_id
admin_groups = [ admin_groups = [
("模型设置", "RobotOutlined", 98),
("模型配置", "RobotOutlined", 6), ("模型配置", "RobotOutlined", 6),
("系统设置", "SettingOutlined", 99), ("系统设置", "SettingOutlined", 99),
] ]
group_ids = {} admin_group_ids: dict[str, str] = {}
for label, icon, order in admin_groups: for label, icon, order in admin_groups:
existing = await db.execute( existing = await db.execute(
select(MenuConfig).where( select(MenuConfig).where(
MenuConfig.label == label, MenuConfig.label == label,
MenuConfig.menu_type == "group", MenuConfig.menu_type == "group",
MenuConfig.menu_target == "admin", MenuConfig.menu_target == "admin",
) ).limit(1)
.limit(1)
) )
group = existing.scalar_one_or_none() group = existing.scalar_one_or_none()
if not group: if group:
admin_group_ids[label] = group.id
else:
gid = generate_id() gid = generate_id()
admin_group_ids[label] = gid
db.add( db.add(
MenuConfig( MenuConfig(
id=gid, id=gid,
@@ -377,35 +421,33 @@ async def _seed_data():
menu_target="admin", menu_target="admin",
) )
) )
group_ids[label] = gid
else:
group_ids[label] = group.id
# (path, label, icon, sort_order, parent_group_label or None) admin_pages = [
admin_menus = [
("/", "数据概览", "DashboardOutlined", 0, None), ("/", "数据概览", "DashboardOutlined", 0, None),
("/users", "用户管理", "UserOutlined", 1, None), ("/users", "用户管理", "UserOutlined", 1, None),
("/credit-records", "交易流水", "WalletOutlined", 2, 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), ("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
("/notifications", "消息推送", "BellOutlined", 5, None), ("/notifications", "消息推送", "BellOutlined", 5, None),
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型配置"), ("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
("/models", "模型配置", "RobotOutlined", 1, "模型"), ("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型"),
("/image-engines", "图片模型", "PictureOutlined", 2, "模型"), ("/models", "模型配置", "RobotOutlined", 1, "模型"),
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型"), ("/image-engines", "图片模型", "PictureOutlined", 2, "模型"),
("/credit-ratios", "积分比例", "CalculatorOutlined", 3, "模型设置"),
("/payment", "支付配置", "DollarOutlined", 1, "系统设置"), ("/payment", "支付配置", "DollarOutlined", 1, "系统设置"),
("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"), ("/industries", "行业配置", "AppstoreOutlined", 2, "系统设置"),
("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"), ("/menu-configs", "菜单配置", "SettingOutlined", 3, "系统设置"),
("/settings", "系统设置", "SettingOutlined", 4, "系统设置"), ("/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( existing = await db.execute(
select(MenuConfig).where( select(MenuConfig).where(
MenuConfig.path == path, MenuConfig.path == path,
MenuConfig.menu_target == "admin", MenuConfig.menu_target == "admin",
) ).limit(1)
.limit(1)
) )
if not existing.scalar_one_or_none(): if not existing.scalar_one_or_none():
db.add( db.add(
@@ -418,7 +460,7 @@ async def _seed_data():
is_active=True, is_active=True,
menu_type="page", menu_type="page",
menu_target="admin", 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_users: int
total_projects: int total_projects: int
total_generations: int total_generations: int
total_records: int
total_revenue: float total_revenue: float
credits_consumed_today: 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): class SmsScene(str, Enum):
register = "register" register = "register"
login = "login" login = "login"
common = "common"
set_password = "set_password" set_password = "set_password"
class SmsSendRequest(BaseModel): class SmsSendRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号") 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="图形验证码令牌,正式环境按配置要求传入") captcha_token: str | None = Field(None, description="图形验证码令牌,正式环境按配置要求传入")
class SmsVerifyRequest(BaseModel): class SmsVerifyRequest(BaseModel):
phone: str = Field(..., pattern=r"^1[3-9]\d{9}$", description="手机号") 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="验证码") code: str = Field(..., min_length=4, max_length=8, description="验证码")
class SmsResponse(BaseModel): class SmsResponse(BaseModel):
message: str 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 任务模块注册统一由 app.tasks.celery_app.CELERY_TASK_IMPORTS 控制。
custom named tasks are registered when workers start.
"""
import logging 这里不要主动 import 子任务模块,避免以下问题:
1. Celery worker 启动时循环导入;
logger = logging.getLogger("video_gen") 2. 新增任务文件后部分 worker 注册不完整;
3. app.tasks.__init__ 被普通业务代码 import 时意外加载全部 Celery 任务。
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
+20 -3
View File
@@ -10,6 +10,22 @@ from app.tasks.async_runner import close_loop, run_async
logger = logging.getLogger("video_gen") 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: def _derive_redis_db(url: str, db_no: int) -> str:
if not url: if not url:
return 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 "") backend_url = settings.CELERY_RESULT_BACKEND or (_derive_redis_db(settings.REDIS_URL, 2) if settings.REDIS_URL else "")
if broker_url: if broker_url:
celery_app = Celery("videogen") celery_app = Celery("videogen", include=CELERY_TASK_IMPORTS)
celery_app.conf.update( celery_app.conf.update(
broker_url=broker_url, broker_url=broker_url,
result_backend=backend_url or broker_url, result_backend=backend_url or broker_url,
imports=CELERY_TASK_IMPORTS,
task_serializer="json", task_serializer="json",
accept_content=["json"], accept_content=["json"],
result_serializer="json", result_serializer="json",
@@ -60,10 +77,10 @@ if broker_url:
"generation.recover_download_tasks_once": {"queue": "gen_result_download"}, "generation.recover_download_tasks_once": {"queue": "gen_result_download"},
"generation.recover_generation_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"}, "module_async.recover_module_async_tasks_once": {"queue": "gen_result_download"},
"user_oauth.update_oauth_accounts": {"queue": "default"},
"app.tasks.cleanup.*": {"queue": "default"}, "app.tasks.cleanup.*": {"queue": "default"},
}, },
) )
celery_app.autodiscover_tasks(["app.tasks"])
else: else:
celery_app = None celery_app = None
@@ -164,4 +181,4 @@ def on_worker_process_shutdown(**kwargs):
except Exception: except Exception:
pass pass
finally: 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="credits" element={<CreditsPage />} />
<Route path="credit-records" element={<UserCenterPage />} /> <Route path="credit-records" element={<UserCenterPage />} />
<Route path="order-records" element={<UserCenterPage />} /> <Route path="order-records" element={<UserCenterPage />} />
<Route path="user-center" element={<UserCenterPage />} />
<Route path="messages" element={<MessagesPage />} /> <Route path="messages" element={<MessagesPage />} />
<Route path="conversation" element={<GenerateConver />} /> <Route path="conversation" element={<GenerateConver />} />
<Route path="initial" element={<InitialReplication />} /> <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()}`); 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 === 'changePwd') { setPwdModalOpen(true); }
else if (key === 'messages') { navigate('/messages'); } else if (key === 'messages') { navigate('/messages'); }
else if (key === 'recharge') { setRechargeModalOpen(true); } else if (key === 'recharge') { setRechargeModalOpen(true); }
else if (key === 'myCredits') { navigate('/credit-records'); } else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/order-records'); } else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
}; };
const handleChangePwd = async () => { const handleChangePwd = async () => {
@@ -410,11 +410,11 @@ const AppLayout: React.FC = () => {
{/* Menu */} {/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}> <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 style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
导航 导航
</div> </div>
)} )} */}
{(() => { {(() => {
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group'); const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
const pages = 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 => { 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)); const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
if (!collapsed) { if (!collapsed) {
const isGroupOpen = !collapsedGroups[g.id];
items.push( items.push(
<div key={g.id}> <div key={g.id}>
<div onClick={() => setCollapsedGroups(prev => ({ ...prev, [g.id]: !prev[g.id] }))} style={{ <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600,
padding: '8px 14px', margin: '8px 0 2px', cursor: 'pointer', padding: '8px 12px 6px', letterSpacing: 1,
color: 'rgba(148,163,184,0.5)', fontSize: 11, fontWeight: 600,
letterSpacing: 0.5,
}}> }}>
<span>{g.label}</span> {g.label}
<span style={{ fontSize: 10, transform: isGroupOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}></span>
</div> </div>
{isGroupOpen && children.map(c => renderMenuItem(c, 1))} {children.map(c => renderMenuItem(c, 1))}
</div> </div>
); );
} else { } else {
+56 -28
View File
@@ -121,6 +121,9 @@ const AIChatPage: React.FC = () => {
const [previewType, setPreviewType] = useState<'image' | 'video'>('image'); const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
// 提示词展开状态
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
// 从URL中提取exp时间戳(支持相对路径和完整URL) // 从URL中提取exp时间戳(支持相对路径和完整URL)
const extractExpTimestamp = (url: string): number | null => { const extractExpTimestamp = (url: string): number | null => {
if (!url) return 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); setTotalnumber(data.total);
}).catch((error) => { }).catch((error) => {
}); });
}, 5000); }, 10000);
// 清理定时器 // 清理定时器
return () => { return () => {
@@ -807,7 +809,7 @@ const AIChatPage: React.FC = () => {
const handleClosePreview = () => { const handleClosePreview = () => {
setPreviewVisible(false); setPreviewVisible(false);
setPreviewUrl(''); setPreviewUrl('');
if (videoRef.current) { if (videoRef.current) {
videoRef.current.pause(); videoRef.current.pause();
} }
@@ -818,7 +820,7 @@ const AIChatPage: React.FC = () => {
e.stopPropagation(); e.stopPropagation();
if (!previewUrl) return; if (!previewUrl) return;
const link = document.createElement('a'); 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'; link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); 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 <Popconfirm
title="确定要删除吗?" title="确定要删除吗?"
onConfirm={async () => { onConfirm={async () => {
@@ -1099,7 +1101,6 @@ const AIChatPage: React.FC = () => {
setGen_list(prev => prev.filter(item => item.id !== msg.id)); setGen_list(prev => prev.filter(item => item.id !== msg.id));
setTotalnumber(prev => prev - 1); setTotalnumber(prev => prev - 1);
} catch (error: any) { } catch (error: any) {
console.log(error);
const errorMsg = error?.response?.data?.message || error?.message || '删除失败'; const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
msgApi.error(errorMsg); msgApi.error(errorMsg);
@@ -1115,8 +1116,8 @@ const AIChatPage: React.FC = () => {
height: 28, height: 28,
borderRadius: 8, borderRadius: 8,
border: 'none', border: 'none',
background: 'rgba(0,0,0,0.05)', background: 'rgba(146, 144, 144, 1)',
color: '#999', color: '#ffffffff',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -1140,29 +1141,56 @@ const AIChatPage: React.FC = () => {
</div> </div>
{/* 文本内容 */} {/* 文本内容 */}
<Tooltip <div
title={msg.originalPrompt} style={{
placement="top" position: 'relative',
style={{ maxWidth: '400px' }} margin: '8px 0',
> padding: '12px 16px',
<p style={{ backgroundColor: '#f8fafc',
margin: '8px 0', borderRadius: 8,
fontSize: 14, border: '1px solid #e2e8f0',
fontSize: 13,
color: '#475569', 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', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
cursor: 'pointer', display: expandedPrompts.has(msg.id) ? 'none' : 'block',
padding: '4px 8px', }}>
borderRadius: 4, {msg.originalPrompt}
transition: 'background-color 0.2s', </div>
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }} {/* 鼠标移入显示:完整内容 */}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }} <div style={{
> maxHeight: 200,
{msg.originalPrompt} overflowY: 'auto',
</p> display: expandedPrompts.has(msg.id) ? 'block' : 'none',
</Tooltip> wordBreak: 'break-word',
}}>
{msg.originalPrompt}
</div>
</div>
{/* 根据 status 显示不同内容 */} {/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */} {/* 生成中 - 显示加载动画 */}
@@ -2354,7 +2382,7 @@ const AIChatPage: React.FC = () => {
<Button key="download" type="primary" onClick={() => { <Button key="download" type="primary" onClick={() => {
if (!attachmentPreviewUrl) return; if (!attachmentPreviewUrl) return;
const link = document.createElement('a'); 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'); link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
+1 -1
View File
@@ -4323,7 +4323,7 @@ const GeneratePage: React.FC = () => {
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => { onClick={() => {
const a = document.createElement("a"); 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.download = `${record.projectName}${type === "video" ? ".mp4" : ".png"}`;
a.click(); a.click();
}} }}
+1 -1
View File
@@ -1507,7 +1507,7 @@ const GeneratedRecord: React.FC = () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.pause(); 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'); window.open(url, '_blank');
}} }}
style={{ flex: 1, borderRadius: 8 }} 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 }}> <div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
<Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}> <Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}>
<Button size="small" icon={<DownloadOutlined />} <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 }}> style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
</Button> </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 { Button, Modal, Input, Table, Upload, Popconfirm } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons'; import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -9,40 +9,12 @@ export default function VideoFrameExtractor() {
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [productName, setProductName] = useState<string>('');
const [videoDuration, setVideoDuration] = useState<number>(0); 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 videoRef = useRef<HTMLVideoElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const hiddenVideoRef = useRef<HTMLVideoElement>(null);
const cleanupResources = useCallback(() => { const cleanupResources = useCallback(() => {
if (videoUrl) { if (videoUrl) {
@@ -51,12 +23,7 @@ export default function VideoFrameExtractor() {
setVideoUrl(''); setVideoUrl('');
setError(''); setError('');
setVideoDuration(0); setVideoDuration(0);
setSegmentStart(0); setProductName('');
setSegmentEnd(0);
setIsSegmentSelected(false);
setIsPlaying(false);
setCurrentTime(0);
setFramePreviews([]);
}, [videoUrl]); }, [videoUrl]);
const handleFileChange = (file: File) => { const handleFileChange = (file: File) => {
@@ -78,213 +45,104 @@ export default function VideoFrameExtractor() {
return false; 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(() => { const handleVideoLoaded = useCallback(() => {
if (videoRef.current) { if (videoRef.current) {
const duration = videoRef.current.duration; setVideoDuration(videoRef.current.duration);
setVideoDuration(duration);
setSegmentStart(0);
setSegmentEnd(Math.min(15, duration));
setIsSegmentSelected(true);
} }
}, []); }, []);
useEffect(() => { const tableData = [
if (videoDuration > 0) { {
generateFramePreviews(); id: 1,
} image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
}, [videoDuration, generateFramePreviews]); originalName: '进圈',
productName: '他趣',
const handleVideoTimeUpdate = useCallback(() => { status: '视频成功',
if (videoRef.current) { createTime: '2026-05-14 17:49:20',
setCurrentTime(videoRef.current.currentTime); },
} {
}, []); id: 2,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
const togglePlay = useCallback(() => { originalName: '香水',
if (!videoRef.current) return; productName: '面霜',
if (videoRef.current.paused) { status: '视频提示词成功',
videoRef.current.play(); createTime: '2026-05-08 08:57:46',
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]);
return ( return (
<div style={{ <div style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%)', padding: '20px' }}>
height: '94vh', <div style={{ maxWidth: 1200, margin: '0 auto' }}>
background: '#ffffff', <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
padding: '20px', <div>
boxSizing: 'border-box', <div style={{
overflow: 'auto' width: 36,
}}> height: 36,
<div style={{ textAlign: 'right', marginBottom: 20 }}> background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
<Button borderRadius: 10,
type="text" display: 'flex',
icon={<FileTextOutlined />} alignItems: 'center',
style={{ justifyContent: 'center',
borderRadius: 6, color: 'white',
fontSize: 16, fontSize: 18,
fontWeight: 600, fontWeight: 600
color: '#656efa' }}>
}}
onClick={() => setIsModalOpen(true)} </div>
> </div>
<div>
</Button> <h1 style={{
</div> fontSize: 22,
fontWeight: 600,
margin: 0,
color: '#1e293b'
}}>
</h1>
<p style={{ fontSize: 13, color: '#94a3b8', margin: 4 }}>
仿
</p>
</div>
</div>
<div style={{ <button
display: 'flex', onClick={() => setIsModalOpen(true)}
flexDirection: 'column', style={{
alignItems: 'center', padding: '10px 20px',
justifyContent: 'center', background: 'white',
minHeight: 'calc(100vh - 100px)' border: '1px solid #e2e8f0',
}}> borderRadius: 8,
<div style={{ textAlign: 'center', marginBottom: 40 }}> cursor: 'pointer',
<div style={{ fontSize: 14,
display: 'flex', color: '#6366f1',
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
gap: 12, gap: 8,
marginBottom: 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={{ <div style={{
width: 36, width: 56,
height: 36, height: 56,
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)', background: 'linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%)',
borderRadius: 10, borderRadius: 16,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: 'white', margin: '0 auto 16px'
fontSize: 20
}}> }}>
<span style={{ fontSize: 28 }}>🎬</span>
</div> </div>
<h1 style={{ <h2 style={{
fontSize: 26, fontSize: 22,
fontWeight: 600, fontWeight: 600,
margin: 0, margin: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
@@ -292,15 +150,15 @@ export default function VideoFrameExtractor() {
WebkitTextFillColor: 'transparent', WebkitTextFillColor: 'transparent',
backgroundClip: 'text' backgroundClip: 'text'
}}> }}>
</h1> </h2>
<p style={{ fontSize: 14, color: '#888', margin: '8px 0 0 0' }}>
MP4MOV 100MB
</p>
</div> </div>
<p style={{ fontSize: 14, color: '#888', margin: 0 }}>
仿
</p>
</div> </div>
<div style={{ width: '100%', maxWidth: 720 }}> <div style={{ width: '100%', maxWidth: 720, margin: '0 auto' }}>
{error && ( {error && (
<div style={{ <div style={{
backgroundColor: '#fff5f5', backgroundColor: '#fff5f5',
@@ -325,7 +183,7 @@ export default function VideoFrameExtractor() {
background: '#faf5ff', background: '#faf5ff',
border: '2px dashed #c4b5fd', border: '2px dashed #c4b5fd',
borderRadius: 24, borderRadius: 24,
padding: '40px 20px' padding: '60px 20px'
}} }}
> >
<div style={{ <div style={{
@@ -344,7 +202,7 @@ export default function VideoFrameExtractor() {
</p> </p>
<p style={{ fontSize: 13, color: '#999', margin: 0 }}> <p style={{ fontSize: 13, color: '#999', margin: 0 }}>
MP4MOV 100MB 3 4-15 MP4MOV 100MB 3
</p> </p>
</Upload.Dragger> </Upload.Dragger>
)} )}
@@ -397,322 +255,149 @@ export default function VideoFrameExtractor() {
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
controls
onLoadedMetadata={handleVideoLoaded} onLoadedMetadata={handleVideoLoaded}
onTimeUpdate={handleVideoTimeUpdate}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onClick={togglePlay}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
maxHeight: 320, maxHeight: 320,
borderRadius: 12, borderRadius: 12,
background: '#000', background: '#000'
cursor: 'pointer'
}} }}
/> />
</div> </div>
{isSegmentSelected && ( {/* 产品名称输入框 */}
<div style={{ <div style={{ marginBottom: 24 }}>
background: '#f8fafc', <label style={{
borderRadius: 16, display: 'block',
padding: '20px' 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', alignItems: 'center',
justifyContent: 'center', gap: 10
gap: 16, }}
marginBottom: 16 >
<span></span>
<span style={{
fontSize: 12,
opacity: 0.85,
fontWeight: 400
}}> }}>
<button 10
onClick={togglePlay} </span>
style={{ </button>
width: 32, </div>
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>
)}
</div> </div>
</div> </div>
)} )}
<div style={{ textAlign: 'center', marginTop: 32 }}> <canvas ref={canvasRef} style={{ display: 'none' }} />
<button
disabled={!videoUrl || !segmentValid} <Modal
style={{ title="创作记录"
padding: '14px 56px', open={isModalOpen}
background: videoUrl && segmentValid onCancel={() => setIsModalOpen(false)}
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)' width={800}
: '#e2e8f0', footer={null}
color: 'white', >
border: 'none', <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
borderRadius: 14, <Input
fontSize: 15, placeholder="搜索产品名称"
fontWeight: 600, style={{ width: 200, borderRadius: 6, marginRight: 8 }}
cursor: videoUrl && segmentValid ? 'pointer' : 'not-allowed', />
boxShadow: videoUrl && segmentValid ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none', <Button type="primary" style={{ borderRadius: 6 }}>
display: 'inline-flex',
alignItems: 'center', </Button>
gap: 10 </div>
}}
> <Table
<span></span> columns={[
<span style={{ {
fontSize: 12, title: '产品图片',
opacity: 0.85, dataIndex: 'image',
fontWeight: 400 key: 'image',
}}> width: 90,
10 render: (image: string) => (
</span> <img
</button> src={image}
</div> 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>
</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> </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 { Tabs } from 'antd';
import { useSearchParams } from 'react-router-dom';
import CreditRecordsPage from './CreditRecordsPage'; import CreditRecordsPage from './CreditRecordsPage';
import OrderRecordsPage from './OrderRecordsPage'; import OrderRecordsPage from './OrderRecordsPage';
const UserCenterPage: React.FC = () => { const UserCenterPage: React.FC = () => {
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('credits'); const [activeTab, setActiveTab] = useState('credits');
useEffect(() => {
const tab = searchParams.get('tab') || 'credits';
if (tab === 'orders') {
setActiveTab('orders');
} else {
setActiveTab('credits');
}
}, [searchParams]);
const tabs = [ const tabs = [
{ {
key: 'credits', key: 'credits',
+10 -2
View File
@@ -13,6 +13,14 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.product_img{
margin-left: 20px;
/* 加载旋转动画 */
@keyframes spinSlow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }