解决冲突
This commit is contained in:
@@ -709,8 +709,15 @@ export async function deleteOAuthAccount(params: DeleteOAuthAccountParams): Prom
|
||||
export async function getAllOAuthAccountList(): Promise<any> {
|
||||
return api.post(`/upload-material/oauth_account_list`);
|
||||
}
|
||||
|
||||
// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
|
||||
export async function submitPreTest(params: { resources_material_ids: any[]; pre_test_template_id: string }): Promise<any> {
|
||||
return api.post(`/resources-material/pre-commit`, params);
|
||||
}
|
||||
// 首页素材案例头
|
||||
export async function getHomeCaseHeader(): Promise<any> {
|
||||
return api.get(`/home-materials/categories`);
|
||||
}
|
||||
// 首页素材按钮资源
|
||||
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
|
||||
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
|
||||
}
|
||||
@@ -143,16 +143,17 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
|
||||
color: '#475569',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{/* <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1' }} />
|
||||
{rawPercent.toFixed(1)}%
|
||||
</span>
|
||||
</span> */}
|
||||
{data.enabled ? (
|
||||
<span style={{ fontWeight: 500, color: isOver ? '#ef4444' : '#1e293b' }}>
|
||||
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
||||
</span>
|
||||
<></>
|
||||
) : (
|
||||
|
||||
<span style={{ fontWeight: 500, color: '#1e293b' }}>
|
||||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1', marginRight: 4 }} />
|
||||
|
||||
当前使用 {usedAuto.val} {usedAuto.unit}
|
||||
</span>
|
||||
)}
|
||||
@@ -162,23 +163,61 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 6,
|
||||
height: 20,
|
||||
background: '#e2e8f0',
|
||||
borderRadius: 3,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
fontSize: 12,
|
||||
color: isOver ? '#ffffffff' : '#000000ff',
|
||||
padding: '0 8px',
|
||||
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} />
|
||||
{rawPercent.toFixed(1)}%
|
||||
</span>
|
||||
{data.enabled ? (
|
||||
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}>
|
||||
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 500, color: '#1e293b' }}>
|
||||
当前使用 {usedAuto.val} {usedAuto.unit}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: `${barPercent}%`,
|
||||
height: '100%',
|
||||
background: isOver
|
||||
? 'linear-gradient(90deg, #ef4444, #dc2626)'
|
||||
: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
|
||||
borderRadius: 3,
|
||||
|
||||
background: rawPercent < 50
|
||||
? 'linear-gradient(90deg, #279951, #b7fad0)'
|
||||
: rawPercent < 85
|
||||
? 'linear-gradient(90deg, #ffd759, #fff6d4)'
|
||||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||||
borderRadius: 2,
|
||||
transition: 'width 0.4s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -283,9 +322,9 @@ const AppLayout: React.FC = () => {
|
||||
const [contactHovered, setContactHovered] = useState(false);
|
||||
const [contactForm] = Form.useForm();
|
||||
const [submittingContact, setSubmittingContact] = useState(false);
|
||||
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
|
||||
x: 24,
|
||||
y: window.innerHeight * 0.75
|
||||
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
|
||||
x: 24,
|
||||
y: window.innerHeight * 0.75
|
||||
}));
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const hasMovedRef = useRef(false);
|
||||
@@ -334,6 +373,7 @@ const AppLayout: React.FC = () => {
|
||||
limitValue: string;
|
||||
limitUnit: string;
|
||||
} | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const PENDING_ORDER_KEY = 'pending_payment_order';
|
||||
|
||||
@@ -387,6 +427,13 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 767);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const handleContactMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
setIsDragging(true);
|
||||
@@ -400,16 +447,16 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
const handleContactMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
|
||||
const deltaX = Math.abs(e.clientX - dragStartRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - dragStartRef.current.y);
|
||||
|
||||
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
hasMovedRef.current = true;
|
||||
}
|
||||
|
||||
|
||||
const newY = Math.max(60, Math.min(window.innerHeight - 60, e.clientY - (dragStartRef.current.y - contactPosition.y)));
|
||||
|
||||
|
||||
setContactPosition(prev => ({ x: prev.x, y: newY }));
|
||||
};
|
||||
|
||||
@@ -417,7 +464,7 @@ const AppLayout: React.FC = () => {
|
||||
const moved = hasMovedRef.current;
|
||||
setIsDragging(false);
|
||||
hasMovedRef.current = false;
|
||||
|
||||
|
||||
if (!moved) {
|
||||
setContactModalOpen(true);
|
||||
}
|
||||
@@ -672,12 +719,12 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!(isGroup || hasChildren) && item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
}}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
@@ -712,7 +759,7 @@ const AppLayout: React.FC = () => {
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{(isGroup || hasChildren) && (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
|
||||
@@ -752,7 +799,7 @@ const AppLayout: React.FC = () => {
|
||||
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
||||
|
||||
>
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
|
||||
@@ -803,9 +850,9 @@ const AppLayout: React.FC = () => {
|
||||
<span>充值积分</span>
|
||||
</div>
|
||||
{/* 资源存储容量展示(来自 getUser.resourceCapacity) */}
|
||||
<StorageCard data={resourceCapacity} />
|
||||
<StorageCard data={resourceCapacity} />
|
||||
|
||||
<div style={{ padding: '16px 16px', flexShrink: 0 ,paddingTop: 0}}>
|
||||
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 0 }}>
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
@@ -832,7 +879,7 @@ const AppLayout: React.FC = () => {
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
||||
}} />
|
||||
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#1e293b', fontSize: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
|
||||
{user?.username}
|
||||
@@ -851,7 +898,7 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -876,7 +923,7 @@ const AppLayout: React.FC = () => {
|
||||
padding: '24px 32px 32px',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
}}>
|
||||
<Outlet />
|
||||
{!isMobile && <Outlet />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -896,7 +943,7 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mobile-content">
|
||||
<Outlet />
|
||||
{isMobile && <Outlet />}
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
@@ -967,7 +1014,7 @@ const AppLayout: React.FC = () => {
|
||||
{menuIcon}
|
||||
</span>
|
||||
)}
|
||||
<span className="mobile-menu-label" style={{
|
||||
<span className="mobile-menu-label" style={{
|
||||
paddingLeft: menuType === 'group' ? 0 : 0,
|
||||
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
|
||||
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
|
||||
@@ -1002,7 +1049,7 @@ const AppLayout: React.FC = () => {
|
||||
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
|
||||
{childIcon}
|
||||
</span>
|
||||
<span className="mobile-menu-label" style={{
|
||||
<span className="mobile-menu-label" style={{
|
||||
color: childActive ? '#4f46e5' : '#64748b',
|
||||
fontWeight: childActive ? 600 : 400,
|
||||
fontSize: 14,
|
||||
@@ -1091,7 +1138,7 @@ const AppLayout: React.FC = () => {
|
||||
</span>
|
||||
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}>充值积分</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
@@ -1133,109 +1180,10 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
footer={null} width={680}
|
||||
width={680}
|
||||
className="recharge-modal"
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: '#ff0000ff',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
|
||||
>联系我们</Typography.Text>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
||||
return (
|
||||
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
||||
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
|
||||
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
|
||||
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
|
||||
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
|
||||
}}>
|
||||
{opt.description && (
|
||||
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
|
||||
}}>{g.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 800, marginTop: 4,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{opt.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 20, marginBottom: 8 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '12px 0 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
@@ -1289,6 +1237,106 @@ const AppLayout: React.FC = () => {
|
||||
确认充值
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: '#ff0000ff',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
|
||||
>联系我们</Typography.Text>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
||||
return (
|
||||
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
||||
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
|
||||
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
|
||||
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
|
||||
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
|
||||
}}>
|
||||
{opt.description && (
|
||||
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
|
||||
}}>{g.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 800, marginTop: 4,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{opt.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1436,7 +1484,7 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<NotificationPopup />
|
||||
|
||||
<div
|
||||
<div
|
||||
className="contact-button-wrapper"
|
||||
style={{
|
||||
right: `${contactPosition.x}px`,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
LayoutOutlined,
|
||||
ArrowUpOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -139,13 +140,52 @@ const AIChatPage: React.FC = () => {
|
||||
setEngineOptions,
|
||||
setEnginesele,
|
||||
setInputValue,
|
||||
currentMedia,
|
||||
setCurrentMedia,
|
||||
} = useAppStore();
|
||||
|
||||
// 获取当前选中引擎的媒体上传限制
|
||||
const currentEngineList = mediaType === 'image' ? enginesele?.image : enginesele?.video;
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImageCount = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideoCount = currentEngine?.maxVideoCount ?? 1;
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const [currentMedia, setCurrentMedia] = useState<{ name: string; type: 'image' | 'video'; url: string }[]>([]);
|
||||
// @ 提及相关状态
|
||||
const [mentionVisible, setMentionVisible] = useState(false);
|
||||
const mentionInputRef = useRef<any>(null);
|
||||
const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// 数字转中文数字(一、二、三、四)
|
||||
const numberToChinese = (num: number): string => {
|
||||
const map = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
if (num <= 10) return map[num];
|
||||
if (num < 20) return '十' + map[num % 10];
|
||||
if (num < 100) {
|
||||
const tens = Math.floor(num / 10);
|
||||
const ones = num % 10;
|
||||
return map[tens] + '十' + (ones ? map[ones] : '');
|
||||
}
|
||||
return String(num);
|
||||
};
|
||||
|
||||
// 根据媒体列表生成标签(图片一、图片二、视频一、视频二...)
|
||||
const generateMediaLabels = (media: { type: 'image' | 'video' }[]) => {
|
||||
let imgCount = 0;
|
||||
let vidCount = 0;
|
||||
return media.map((m) => {
|
||||
if (m.type === 'image') {
|
||||
imgCount++;
|
||||
return `图片${imgCount}`;
|
||||
} else {
|
||||
vidCount++;
|
||||
return `视频${vidCount}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
@@ -393,44 +433,20 @@ const AIChatPage: React.FC = () => {
|
||||
setCountType(targetEngine.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
getCreditRatios()
|
||||
.then((data: any) => {
|
||||
// console.log('积分', data);
|
||||
|
||||
setCreditRatios(data.video || []);
|
||||
setCimage(data.image || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
calculateCredits().then((data: any) => {
|
||||
// console.log('积分计算', data);
|
||||
// 保存积分计算数据
|
||||
setCreditCalculationData(data);
|
||||
})
|
||||
getgen_list(Pagebreak).then((data: any) => {
|
||||
let mess_list = data.items
|
||||
let total = data.total
|
||||
setGen_list(mess_list)
|
||||
setTotalnumber(total)
|
||||
})
|
||||
getParameters()
|
||||
.then((data) => {
|
||||
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
|
||||
let supportedResolutions = [];
|
||||
let twokwidth = [];
|
||||
let fourkwidth = [];
|
||||
let supportedSizes = (data as any).engine?.image?.[0]?.supported_sizes || {};
|
||||
let supportedResolutions: string[] = [];
|
||||
let twokwidth: string[] = [];
|
||||
let fourkwidth: string[] = [];
|
||||
|
||||
for (let key in supportedSizes["2K"]) {
|
||||
supportedResolutions.push(key);
|
||||
twokwidth.push(supportedSizes["2K"][key]);
|
||||
}
|
||||
|
||||
const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
|
||||
const newRatioOptions = supportedResolutions.map((res: any) => ({
|
||||
value: res,
|
||||
label: String(index),
|
||||
label: res,
|
||||
}));
|
||||
setRatioOptions(newRatioOptions);
|
||||
|
||||
@@ -444,8 +460,67 @@ const AIChatPage: React.FC = () => {
|
||||
{ value: '2K', label: '高清 2K' },
|
||||
{ value: '4K', label: '超清 4K' },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => {
|
||||
});
|
||||
// getCreditRatios()
|
||||
// .then((data: any) => {
|
||||
// // console.log('积分', data);
|
||||
|
||||
// setCreditRatios(data.video || []);
|
||||
// setCimage(data.image || []);
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// });
|
||||
calculateCredits().then((data: any) => {
|
||||
// console.log('积分计算', data);
|
||||
// 保存积分计算数据
|
||||
setCreditCalculationData(data);
|
||||
})
|
||||
getgen_list(Pagebreak).then((data: any) => {
|
||||
let mess_list = data.items
|
||||
let total = data.total
|
||||
setGen_list(mess_list)
|
||||
setTotalnumber(total)
|
||||
})
|
||||
// getParameters()
|
||||
// .then((data) => {
|
||||
// let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
|
||||
// let supportedResolutions = [];
|
||||
// let twokwidth = [];
|
||||
// let fourkwidth = [];
|
||||
|
||||
// for (let key in supportedSizes["2K"]) {
|
||||
// supportedResolutions.push(key);
|
||||
// twokwidth.push(supportedSizes["2K"][key]);
|
||||
// }
|
||||
|
||||
// const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
|
||||
// value: res,
|
||||
// label: String(index),
|
||||
// }));
|
||||
// setRatioOptions(newRatioOptions);
|
||||
|
||||
// for (let key in supportedSizes["4K"]) {
|
||||
// fourkwidth.push(supportedSizes["4K"][key]);
|
||||
// }
|
||||
|
||||
// const newWidthandHeight = [twokwidth, fourkwidth];
|
||||
// setWidthandHeight(newWidthandHeight);
|
||||
// setResolutionOptions([
|
||||
// { value: '2K', label: '高清 2K' },
|
||||
// { value: '4K', label: '超清 4K' },
|
||||
// ]);
|
||||
// })
|
||||
// .catch(() => { });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -770,6 +845,18 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前选中引擎的限制
|
||||
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImage = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideo = currentEngine?.maxVideoCount ?? 1;
|
||||
|
||||
// 根据 mediaType 判断是否允许该文件类型
|
||||
if (mediaType === 'image' && isVideo) {
|
||||
message.error('图片模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
@@ -777,17 +864,17 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证图片数量(最多4张)
|
||||
// 验证图片数量
|
||||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||||
if (isImage && imageCount >= 4) {
|
||||
message.error('最多上传4张图片');
|
||||
if (isImage && imageCount >= maxImage) {
|
||||
message.error(`该引擎最多上传${maxImage}张图片`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证视频数量(最多1个)
|
||||
// 验证视频数量
|
||||
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
||||
if (isVideo && videoCount >= 1) {
|
||||
message.error('最多上传1个视频');
|
||||
if (isVideo && videoCount >= maxVideo) {
|
||||
message.error(`该引擎最多上传${maxVideo}个视频`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -799,11 +886,15 @@ const AIChatPage: React.FC = () => {
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
const res = await uploadFn(file);
|
||||
// 添加到统一的媒体列表
|
||||
setCurrentMedia((prev) => [...prev, {
|
||||
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
|
||||
const newList = [...currentMedia, {
|
||||
name: file.name,
|
||||
type: isImage ? 'image' : 'video',
|
||||
type: mediaType,
|
||||
url: res.url,
|
||||
}]);
|
||||
label: '',
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`${isImage ? '图片' : '视频'}上传成功`);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
@@ -817,7 +908,9 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
|
||||
const handleRemoveMedia = (index: number) => {
|
||||
setCurrentMedia((prev) => prev.filter((_, i) => i !== index));
|
||||
const newList = currentMedia.filter((_, i) => i !== index);
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
};
|
||||
|
||||
|
||||
@@ -828,6 +921,63 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检测光标前的 @ 符号
|
||||
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const atMatch = textBeforeCursor.match(/@([^@\s]*)$/);
|
||||
|
||||
if (atMatch && currentMedia.length > 0) {
|
||||
setMentionVisible(true);
|
||||
return true;
|
||||
}
|
||||
setMentionVisible(false);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 输入框变化处理
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setInputValue(value);
|
||||
const textarea = e.target;
|
||||
checkMention(textarea, value);
|
||||
};
|
||||
|
||||
// 键盘事件处理(ESC 关闭提及、Tab/Enter 选择)
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mentionVisible) {
|
||||
if (e.key === 'Escape') {
|
||||
setMentionVisible(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 插入 @ 提及
|
||||
const insertMention = (label: string) => {
|
||||
const textarea = mentionInputRef.current?.resizableTextArea?.textArea;
|
||||
if (!textarea) return;
|
||||
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBefore = inputValue.slice(0, cursorPos);
|
||||
const textAfter = inputValue.slice(cursorPos);
|
||||
|
||||
const atIndex = textBefore.lastIndexOf('@');
|
||||
if (atIndex === -1) {
|
||||
setMentionVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = textBefore.slice(0, atIndex) + `@${label} ` + textAfter;
|
||||
setInputValue(newValue);
|
||||
setMentionVisible(false);
|
||||
|
||||
setTimeout(() => {
|
||||
const pos = atIndex + label.length + 2;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleClosePreview = () => {
|
||||
@@ -961,9 +1111,9 @@ const AIChatPage: React.FC = () => {
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
|
||||
<div>
|
||||
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ width: '15%', height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, textAlign: 'center' }}>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
@@ -971,7 +1121,7 @@ const AIChatPage: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
}}>
|
||||
AI创作
|
||||
</h2>
|
||||
@@ -979,7 +1129,7 @@ const AIChatPage: React.FC = () => {
|
||||
输入想法、剧本或上传参考,智能生成视频/图片
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
|
||||
<div style={{ width: '15%', height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1125,8 +1275,55 @@ const AIChatPage: React.FC = () => {
|
||||
{msg.genType === 'image' ? '图片生成' : '视频生成'}
|
||||
</span>
|
||||
</div>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
|
||||
{/* 附件详情 - 右上角 */}
|
||||
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
|
||||
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮 - 右下角 */}
|
||||
<div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 100, display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInputValue(msg.originalPrompt || '');
|
||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
})));
|
||||
} else {
|
||||
setCurrentMedia([]);
|
||||
}
|
||||
msgApi.success('已加载到编辑区');
|
||||
}}
|
||||
style={{
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
color: '#6366f1',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined style={{ fontSize: 12 }} />
|
||||
重新编辑
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
onConfirm={async () => {
|
||||
@@ -1148,8 +1345,8 @@ const AIChatPage: React.FC = () => {
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
@@ -1159,7 +1356,8 @@ const AIChatPage: React.FC = () => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
@@ -1171,9 +1369,9 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
@@ -1261,7 +1459,10 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(254, 226, 226, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<WarningOutlined style={{ color: '#ef4444', fontSize: 22 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败</span>
|
||||
<span style={{ fontSize: 14, color: '#94a3b8' }}>生成失败(积分已退)</span>
|
||||
{msg.errorMessage && (
|
||||
<span style={{ fontSize: 13, color: '#ef4444', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1300,11 +1501,6 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
)}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
|
||||
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}>附件详情</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1444,7 +1640,7 @@ const AIChatPage: React.FC = () => {
|
||||
{currentMedia.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
|
||||
{currentMedia.map((media, idx) => (
|
||||
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0 }}>
|
||||
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
{media.type === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
@@ -1457,6 +1653,9 @@ const AIChatPage: React.FC = () => {
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
{/* 删除已上传媒体按钮 */}
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
@@ -1495,14 +1694,18 @@ const AIChatPage: React.FC = () => {
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* 上传按钮 */}
|
||||
<Upload
|
||||
accept="image/*,video/*"
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={`图片${currentMedia.filter(m => m.type === 'image').length}/4,视频${currentMedia.filter(m => m.type === 'video').length}/1`}>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
@@ -1539,10 +1742,15 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
{/* 文本输入框 */}
|
||||
<TextArea
|
||||
ref={mentionInputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..."
|
||||
placeholder={mediaType === 'image'
|
||||
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
|
||||
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
|
||||
}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -1554,14 +1762,64 @@ const AIChatPage: React.FC = () => {
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
color: '#1e293b',
|
||||
// resize: 'none',
|
||||
|
||||
// boxShadow: 'none',
|
||||
// padding: '8px 12px',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* @ 提及下拉列表 */}
|
||||
{mentionVisible && currentMedia.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 70,
|
||||
marginBottom: 8,
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 8px 28px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: 6,
|
||||
minWidth: 160,
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{currentMedia.map((media, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => insertMention(media.label)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: '#334155',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
|
||||
e.currentTarget.style.color = '#6366f1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#334155';
|
||||
}}
|
||||
>
|
||||
{media.type === 'image' ? (
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
|
||||
) : (
|
||||
<VideoCameraOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
|
||||
)}
|
||||
<span>{media.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Tabs, Tag, Upload, message } from 'antd';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Input, Tabs, Tag, Upload, message, Modal } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ScissorOutlined,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
ThunderboltOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getmedit } from '../api';
|
||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
||||
|
||||
import hot from '../assets/homebtn1.png';
|
||||
import mashup from '../assets/homebtn2.png';
|
||||
@@ -39,6 +40,29 @@ const HomePage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('project');
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [mockVideos, setMockVideos] = useState<any[]>([]);
|
||||
const [caseHeader, setCaseHeader] = useState<any[]>([]);
|
||||
const [activeCaseTab, setActiveCaseTab] = useState<string>('');
|
||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getHomeCaseHeader().then((res: any) => {
|
||||
// console.log('caseHeader:', res);
|
||||
if (res?.items?.length > 0) {
|
||||
setCaseHeader(res.items);
|
||||
const firstId = res.items[0].id;
|
||||
setActiveCaseTab(firstId);
|
||||
// 初始请求第一个 tab 的数据
|
||||
getHomeCaseButton(firstId).then((btnRes: any) => {
|
||||
console.log('caseButton:', btnRes);
|
||||
if (btnRes?.categories?.[0]?.assets) {
|
||||
setCaseAssets(btnRes.categories[0].assets);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
@@ -75,21 +99,21 @@ const HomePage: React.FC = () => {
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
|
||||
title: '爆款开头复刻',
|
||||
description: '一键复刻热门视频开篇,快速替换自有商品素材',
|
||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
||||
action: '立即创作',
|
||||
path: '/initial',
|
||||
},
|
||||
{
|
||||
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
|
||||
title: '批量混剪',
|
||||
description: '多素材批量自动剪辑,智能筛选高清优质镜头片段',
|
||||
title: '拆镜复刻',
|
||||
description: '一键拆解画面分镜,助力仿拍或创作',
|
||||
action: '开始混剪',
|
||||
path: '/removelens',
|
||||
},
|
||||
{
|
||||
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
|
||||
title: 'AI成片',
|
||||
description: 'AI全自动快速出片,支持文案生成/上传参考素材制作',
|
||||
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
||||
action: '立即生成',
|
||||
path: '/conversation',
|
||||
},
|
||||
@@ -707,8 +731,6 @@ const HomePage: React.FC = () => {
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
|
||||
|
||||
}}>
|
||||
{(() => {
|
||||
// 显示所属模块,而非媒体类型
|
||||
@@ -773,68 +795,176 @@ const HomePage: React.FC = () => {
|
||||
精选优质作品参考
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
{/* <div style={{
|
||||
fontSize: 13, color: '#6366f1', cursor: 'pointer', fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
}}>
|
||||
更多案例
|
||||
<ArrowRightOutlined style={{ fontSize: 11 }} />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="stagger-children" style={{ display: 'flex', gap: 14 }}>
|
||||
{materialCases.map((caseUrl, index) => (
|
||||
{/* Tab切换 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeCaseTab}
|
||||
onChange={(key) => {
|
||||
setActiveCaseTab(key);
|
||||
getHomeCaseButton(key).then((btnRes: any) => {
|
||||
if (btnRes?.categories?.[0]?.assets) {
|
||||
setCaseAssets(btnRes.categories[0].assets);
|
||||
} else {
|
||||
setCaseAssets([]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
items={caseHeader.map((item: any) => ({
|
||||
key: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
className="homepage-tabs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== 素材案例列表(与近期作品一致) ========== */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{caseAssets.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '60px 0',
|
||||
textAlign: 'center',
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||
<div>暂无素材</div>
|
||||
</div>
|
||||
) : caseAssets.map((asset: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
key={asset.id || index}
|
||||
className="project-card"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
style={{
|
||||
flex: 1,
|
||||
aspectRatio: '16/9',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: '#f1f5f9',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
|
||||
if (overlay) overlay.style.opacity = '1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
|
||||
if (overlay) overlay.style.opacity = '0';
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={caseUrl}
|
||||
alt={`素材案例 ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
案例悬停遮罩(由父级 hover 触发)
|
||||
<div
|
||||
className="case-overlay"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, transparent 40%, rgba(99,102,241,0.75) 100%)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
padding: 14,
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
案例 {index + 1}
|
||||
{/* 媒体区域 16:9 */}
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
||||
{asset.mediaType === 'video' ? (
|
||||
<>
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
alt={asset.title || `素材 ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{asset.title || `素材 ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 预览弹窗 ========== */}
|
||||
<Modal
|
||||
open={!!previewAsset}
|
||||
onCancel={() => {
|
||||
previewVideoRef.current?.pause();
|
||||
setPreviewAsset(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={760}
|
||||
centered
|
||||
className="preview-modal"
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 固定比例容器 16:9 */}
|
||||
<div style={{
|
||||
width: '100%',
|
||||
paddingTop: '56.25%',
|
||||
position: 'relative',
|
||||
// background: '#000',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{previewAsset?.mediaType === 'video' ? (
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
|
||||
controls
|
||||
autoPlay
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
||||
alt={previewAsset?.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 底部标题栏 */}
|
||||
{previewAsset?.title && (
|
||||
<div style={{
|
||||
padding: '14px 20px',
|
||||
fontSize: 14,
|
||||
color: '#4b5563',
|
||||
fontWeight: 500,
|
||||
borderTop: '1px solid #f1f5f9',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{previewAsset.title}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -459,7 +459,8 @@ const GenerateConver: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
爆款开头复刻
|
||||
</h2>
|
||||
|
||||
@@ -125,7 +125,8 @@ const ProjectsPage: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
我的项目
|
||||
</h2>
|
||||
|
||||
@@ -186,7 +186,8 @@ export default function VideoFrameExtractor() {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
backgroundClip: 'text',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
AI视频拆镜工作台
|
||||
</h2>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface AppState {
|
||||
};
|
||||
enginesele: any;
|
||||
inputValue: string;
|
||||
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[];
|
||||
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (name: string, industry: Industry) => Promise<Project>;
|
||||
@@ -58,6 +59,7 @@ interface AppState {
|
||||
setVideoResolution: (resolution: string) => void;
|
||||
setEnginesele: (enginesele: any) => void;
|
||||
setInputValue: (inputValue: string) => void;
|
||||
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[]) => void;
|
||||
resetGenerationConfig: () => void;
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
enginesele: [],
|
||||
inputValue: '',
|
||||
currentMedia: [],
|
||||
|
||||
fetchProjects: async () => {
|
||||
set({ loading: true });
|
||||
@@ -172,6 +175,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
setVideoResolution: (resolution) => set({ videoResolution: resolution }),
|
||||
setEnginesele: (enginesele) => set({ enginesele }),
|
||||
setInputValue: (inputValue) => set({ inputValue }),
|
||||
setCurrentMedia: (currentMedia) => set({ currentMedia }),
|
||||
resetGenerationConfig: () => set({
|
||||
mediaType: 'image',
|
||||
countType: '请选择',
|
||||
@@ -189,5 +193,6 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
enginesele: [],
|
||||
inputValue: '',
|
||||
currentMedia: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user