diff --git a/api/movie_queue.ts b/api/movie_queue.ts index 273da29..a20d442 100644 --- a/api/movie_queue.ts +++ b/api/movie_queue.ts @@ -1,5 +1,5 @@ import { ApiResponse } from './common'; -import { showQueueNotification } from '../components/QueueBox/QueueNotification2'; +import { showH5QueueNotification } from '../components/QueueBox/H5QueueNotication'; import { notification } from 'antd'; /** 队列状态枚举 */ @@ -66,6 +66,7 @@ export async function withQueuePolling( let attempts = 0; let lastNotificationPosition: number | undefined; let isCancelled = false; + let closeModal: (() => void) | null = null; // 生成唯一的轮询ID const pollId = Math.random().toString(36).substring(7); @@ -73,7 +74,8 @@ export async function withQueuePolling( // 创建取消函数 const cancel = () => { isCancelled = true; - notification.destroy(); // 关闭通知 + try { closeModal?.(); } catch {} + notification.destroy(); // 兼容旧弹层 onCancel?.(); cancelTokens.delete(pollId); }; @@ -100,7 +102,19 @@ export async function withQueuePolling( // 如果位置发生变化,更新通知 if ((position !== lastNotificationPosition || status === QueueStatus.PROCESS) && position !== undefined && waiting !== undefined) { - showQueueNotification(position, waiting, status, cancel); + // 打开或更新 H5 弹窗(仅允许 Cancel 关闭,Refresh 触发刷新) + try { closeModal?.(); } catch {} + closeModal = showH5QueueNotification( + position, + waiting, + status, + cancel, + async () => { + // 触发一次立刻刷新:重置 attempts 的等待,直接递归调用 poll() + // 不关闭弹窗,由 showH5QueueNotification 保持打开 + attempts = Math.max(0, attempts - 1); + } + ); lastNotificationPosition = position; } @@ -120,15 +134,18 @@ export async function withQueuePolling( // 如果状态为ready,结束轮询 if (response.code !== 202 && response.data) { - notification.destroy(); // 关闭通知 + try { closeModal?.(); } catch {} + notification.destroy(); // 兼容旧弹层 onSuccess?.(response.data); return response; } - notification.destroy(); // 关闭通知 + try { closeModal?.(); } catch {} + notification.destroy(); // 兼容旧弹层 return response; } catch (error) { - notification.destroy(); // 关闭通知 + try { closeModal?.(); } catch {} + notification.destroy(); // 兼容旧弹层 if (error instanceof Error) { onError?.(error); } diff --git a/components/QueueBox/H5QueueNotication.tsx b/components/QueueBox/H5QueueNotication.tsx new file mode 100644 index 0000000..4fd062a --- /dev/null +++ b/components/QueueBox/H5QueueNotication.tsx @@ -0,0 +1,167 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; +import { createRoot, Root } from 'react-dom/client'; + +type QueueStatus = 'waiting' | 'process' | string; + +interface H5QueueNotificationProps { + position: number; + estimatedMinutes: number; + status: QueueStatus; + onCancel?: () => void; + onRefresh?: () => void; + onClose?: () => void; +} + +function H5QueueNotificationModal(props: H5QueueNotificationProps) { + const { position, estimatedMinutes, status, onCancel, onClose } = props; + const containerRef = useRef(null); + + // 禁用 ESC 关闭,保留空 effect 结构便于未来拓展 + useEffect(() => { + return () => {}; + }, []); + + useEffect(() => { + // Prevent background scroll on mobile while modal is open + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = originalOverflow; + }; + }, []); + + const message = status === 'process' + ? 'Your work is being produced. Please wait until it is completed before creating a new work.' + : `Your work is waiting for production at the ${position} position`; + + return ( +
+
+ {/* 去除右上角关闭按钮,避免除取消以外的关闭路径 */} + +
+
+ 🎬 +
+

Queue Reminder

+
+ +
+

+ {message} +

+ + {status !== 'process' && ( +
+
+
Position
+
#{position}
+
+
+
ETA
+
~{estimatedMinutes} min
+
+
+ )} +
+ +
+ + {status !== 'process' && ( + + )} +
+
+
+ ); +} + +/** + * Opens a lightweight H5-styled dark modal queue notification. + * @param {number} position - Current queue position. + * @param {number} estimatedMinutes - Estimated waiting minutes. + * @param {QueueStatus} status - Queue status: 'waiting' | 'process' | string. + * @param {() => void} onCancel - Callback when user confirms cancel. + * @returns {() => void} - Close function to dismiss the modal programmatically. + */ +export function showH5QueueNotification( + position: number, + estimatedMinutes: number, + status: QueueStatus, + onCancel?: () => void, + onRefresh?: () => void +): () => void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return () => {}; + } + + const mount = document.createElement('div'); + mount.setAttribute('data-alt', 'h5-queue-root'); + document.body.appendChild(mount); + + let root: Root | null = null; + try { + root = createRoot(mount); + } catch { + // Fallback cleanup if root creation fails + document.body.removeChild(mount); + return () => {}; + } + + const close = () => { + try { + root?.unmount(); + } finally { + if (mount.parentNode) { + mount.parentNode.removeChild(mount); + } + } + }; + + root.render( + + ); + + return close; +} + + diff --git a/components/layout/H5TopBar.tsx b/components/layout/H5TopBar.tsx index b92adc8..d458d66 100644 --- a/components/layout/H5TopBar.tsx +++ b/components/layout/H5TopBar.tsx @@ -172,7 +172,10 @@ export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) { const handleCustomAmountBuy = async () => { const amount = parseInt(customAmount); - if (isNaN(amount) || amount <= 0) return; + if (isNaN(amount) || amount < 50) { + window.msg.warning("Minimum purchase is 50 credits."); + return; + } await handleBuyTokens(amount); setCustomAmount(""); }; diff --git a/components/layout/top-bar.tsx b/components/layout/top-bar.tsx index b79a0fe..9120912 100644 --- a/components/layout/top-bar.tsx +++ b/components/layout/top-bar.tsx @@ -113,11 +113,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe const handleCustomAmountBuy = async () => { const amount = parseInt(customAmount); if (isNaN(amount) || amount < 50) { - showInsufficientPointsNotification({ - current_balance: credits, - required_tokens: 50, - message: "Minimum purchase is 50 credits.", - }); + window.msg.warning("Minimum purchase is 50 credits."); return; } await handleBuyTokens(amount); diff --git a/utils/notifications.tsx b/utils/notifications.tsx index f19b0a3..a89b27a 100644 --- a/utils/notifications.tsx +++ b/utils/notifications.tsx @@ -1,131 +1,138 @@ -import { notification } from 'antd'; -import { useRouter } from 'next/router'; +"use client"; -type NotificationType = 'success' | 'info' | 'warning' | 'error'; +import React, { useEffect, useRef } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { BadgeCent } from 'lucide-react'; -const darkGlassStyle = { - background: 'rgba(30, 32, 40, 0.95)', - backdropFilter: 'blur(10px)', - WebkitBackdropFilter: 'blur(10px)', - border: '1px solid rgba(255, 255, 255, 0.08)', - borderRadius: '8px', - boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)', - padding: '12px 16px', -}; - -const messageStyle = { - fontSize: '13px', - fontWeight: 500, - color: '#ffffff', - marginBottom: '6px', - display: 'flex', - alignItems: 'center', - gap: '6px', -}; - -const iconStyle = { - color: '#F6B266', // 警告图标颜色 - background: 'rgba(246, 178, 102, 0.15)', - padding: '4px', - borderRadius: '6px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}; - -const descriptionStyle = { - fontSize: '12px', - color: 'rgba(255, 255, 255, 0.65)', - marginBottom: '12px', - lineHeight: '1.5', -}; - -const btnStyle = { - color: 'rgb(250 173 20 / 90%)', - background: 'transparent', - border: 'none', - cursor: 'pointer', - padding: 0, - fontSize: '12px', - fontWeight: 500, - textDecoration: 'underline', - textUnderlineOffset: '2px', - textDecorationColor: 'rgb(250 173 20 / 60%)', - transition: 'all 0.2s ease', -}; - -/** - * 显示积分不足通知 - * @description 在右上角显示一个带有充值链接的积分不足提醒 - */ -const INSUFFICIENT_POINTS_KEY = 'insufficient-points-notification'; - -export const showInsufficientPointsNotification = (detail?: { +interface InsufficientDetail { current_balance?: number; required_tokens?: number; message?: string; -}) => { - // 先关闭现有的通知 - notification.destroy(INSUFFICIENT_POINTS_KEY); - - // 每次都生成新的 key - const uniqueKey = `${INSUFFICIENT_POINTS_KEY}-${Date.now()}`; +} - notification.warning({ - key: uniqueKey, - message: null, - description: ( -
-

- Insufficient credits reminder -

-

- {detail?.message || 'Your credits are insufficient, please upgrade to continue.'} - {detail?.current_balance !== undefined && detail?.required_tokens !== undefined && ( - <> -
- - Current balance: {detail.current_balance} / Required: {detail.required_tokens} - - - )} -

- -
- ), - duration: 0, - placement: 'topRight', - style: darkGlassStyle, - className: 'dark-glass-notification', - closeIcon: ( - - ), - }); -}; +
+
+ +
+

+ {titleText} +

+
+ +
+

+ {descriptionText} +

+ + {(typeof detail?.current_balance !== 'undefined') && (typeof detail?.required_tokens !== 'undefined') && ( +
+
+
Current balance
+
{detail?.current_balance}
+
+
+
Required
+
{detail?.required_tokens}
+
+
+ )} +
+ +
+ + +
+ + + ); +} /** - * 全局配置通知样式 + * Opens an H5-styled modal to notify insufficient credits. + * @param {InsufficientDetail} detail - optional detail including balances and message. */ -notification.config({ - maxCount: 3, // 最多同时显示3个通知 -}); +export function showInsufficientPointsNotification(detail?: InsufficientDetail): void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const mount = document.createElement('div'); + mount.setAttribute('data-alt', 'insufficient-modal-root'); + document.body.appendChild(mount); + + let root: Root | null = null; + try { + root = createRoot(mount); + } catch { + if (mount.parentNode) { + mount.parentNode.removeChild(mount); + } + return; + } + + const close = () => { + try { + root?.unmount(); + } finally { + if (mount.parentNode) { + mount.parentNode.removeChild(mount); + } + } + }; + + root.render( + + ); +} +