diff --git a/screenshots/ecommerce-1024.png b/screenshots/ecommerce-1024.png new file mode 100644 index 0000000..d12143e Binary files /dev/null and b/screenshots/ecommerce-1024.png differ diff --git a/screenshots/ecommerce-1366.png b/screenshots/ecommerce-1366.png new file mode 100644 index 0000000..7c757fe Binary files /dev/null and b/screenshots/ecommerce-1366.png differ diff --git a/screenshots/ecommerce-1440.png b/screenshots/ecommerce-1440.png new file mode 100644 index 0000000..3d9a662 Binary files /dev/null and b/screenshots/ecommerce-1440.png differ diff --git a/screenshots/ecommerce-1920.png b/screenshots/ecommerce-1920.png new file mode 100644 index 0000000..98302fa Binary files /dev/null and b/screenshots/ecommerce-1920.png differ diff --git a/screenshots/ecommerce-hero.png b/screenshots/ecommerce-hero.png new file mode 100644 index 0000000..938d54b Binary files /dev/null and b/screenshots/ecommerce-hero.png differ diff --git a/screenshots/home-features-1024.png b/screenshots/home-features-1024.png new file mode 100644 index 0000000..98c815b Binary files /dev/null and b/screenshots/home-features-1024.png differ diff --git a/screenshots/home-features-1366.png b/screenshots/home-features-1366.png new file mode 100644 index 0000000..e8ded83 Binary files /dev/null and b/screenshots/home-features-1366.png differ diff --git a/screenshots/home-features-1440.png b/screenshots/home-features-1440.png new file mode 100644 index 0000000..77915f3 Binary files /dev/null and b/screenshots/home-features-1440.png differ diff --git a/screenshots/home-features-1920.png b/screenshots/home-features-1920.png new file mode 100644 index 0000000..a87dc13 Binary files /dev/null and b/screenshots/home-features-1920.png differ diff --git a/src/App.tsx b/src/App.tsx index 9c6c0fb..d57bab5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import ToastContainer from "./components/toast/ToastContainer"; import { toast } from "./components/toast/toastStore"; import { aiGenerationClient } from "./api/aiGenerationClient"; import { keyServerClient } from "./api/keyServerClient"; +import { setUserMaxConcurrency } from "./api/generationConcurrency"; import { notificationClient } from "./api/notificationClient"; import { SERVER_SESSION_REPLACED_EVENT, @@ -32,6 +33,7 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi const CommunityPage = lazy(() => import("./features/community/CommunityPage")); const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage")); const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); +const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); @@ -108,6 +110,7 @@ const VIEW_KEYS = new Set([ "more", "communityReview", "communityCaseAdd", + "betaApplications", "report", "providerHealth", "userAgreement", @@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set([ "community", "communityReview", "communityCaseAdd", + "betaApplications", "assets", "ecommerce", "ecommerceHub", @@ -156,6 +160,8 @@ function normalizeViewKey(rawView: string): WebViewKey { ? "communityReview" : rawView === "community-case-add" ? "communityCaseAdd" + : rawView === "beta-applications" || rawView === "beta-application-review" + ? "betaApplications" : rawView; return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } @@ -198,7 +204,7 @@ function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCan description: payload.prompt || "从生成结果进入画布继续创作。", source: "blank", settings: { - model: payload.resultType === "video" ? "Seedance 2.0" : "Nano Banana Pro", + model: payload.resultType === "video" ? "Seedance 2.0" : "omni-水果 Pro", ratio: payload.resultType === "video" ? "16:9" : "1:1", duration: payload.resultType === "video" ? "6s" : "0s", resolution: payload.resultType === "video" ? "720p" : "2K", @@ -470,6 +476,7 @@ function App() { const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { clearAllUserStorage(); clearSessionState(); + setUserMaxConcurrency(null); setProjects([]); setProjectsLoaded(true); setUsage(emptyUsageSummary); @@ -578,6 +585,7 @@ function App() { const nextSession = await keyServerClient.getCurrentSession(); if (cancelled) return; setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); await hydrateAccountData(nextSession); }; @@ -610,6 +618,7 @@ function App() { if (cancelled) return; if (nextSession) { setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); } else { clearAuthenticatedState({ resetView: true }); } @@ -947,6 +956,7 @@ function App() { async (nextSession: WebUserSession) => { hideSessionReplaced(); setSession(nextSession); + setUserMaxConcurrency(nextSession?.user?.maxConcurrency); await hydrateAccountData(nextSession); if (nextSession.user.email && !nextSession.user.emailVerified) { @@ -1010,7 +1020,7 @@ function App() { previewUrl: payload.resultUrl, params: payload.resultType === "video" ? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-video" } - : { model: "Nano Banana Pro", aspectRatio: "1:1", imageSize: "2K" }, + : { model: "omni-水果 Pro", aspectRatio: "1:1", imageSize: "2K" }, assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined, }, ]; @@ -1302,6 +1312,8 @@ function App() { onOpenReview={() => handleSetView("communityReview")} /> ); + case "betaApplications": + return ; case "workbench": return ( typeof item === "string") : []; +} + +function normalizeStatus(value: unknown): BetaApplicationStatus { + return value === "approved" || value === "rejected" ? value : "pending"; +} + +function normalizeApplication(raw: unknown): BetaApplicationItem { + const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + return { + id: Number(item.id) || 0, + userId: readNumberOrNull(item.userId), + username: readNullableString(item.username), + name: readString(item.name), + phone: readString(item.phone), + wechat: readString(item.wechat), + industry: readString(item.industry), + company: readString(item.company), + city: readString(item.city), + aiTools: readString(item.aiTools), + aiDuration: readString(item.aiDuration), + aiTrack: readString(item.aiTrack), + aiDirection: readStringArray(item.aiDirection), + weeklyUsage: readString(item.weeklyUsage), + feedbackWilling: readString(item.feedbackWilling), + wantFeature: readStringArray(item.wantFeature), + selfStatement: readString(item.selfStatement), + signature: readString(item.signature), + agreeRules: item.agreeRules === true, + status: normalizeStatus(item.status), + inviteCode: readNullableString(item.inviteCode), + reviewNote: readNullableString(item.reviewNote), + reviewedBy: readNumberOrNull(item.reviewedBy), + reviewerUsername: readNullableString(item.reviewerUsername), + reviewedAt: readNullableString(item.reviewedAt), + ipAddress: readNullableString(item.ipAddress), + userAgent: readNullableString(item.userAgent), + createdAt: readString(item.createdAt), + updatedAt: readString(item.updatedAt), + }; +} + +export const betaApplicationClient = { + async submit(input: BetaApplicationInput): Promise { + const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", { + method: "POST", + body: input, + maxRetries: 0, + fallbackMessage: "提交内测申请失败", + }); + return payload.application; + }, + + async listAdminApplications(status?: BetaApplicationStatus | ""): Promise { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, { + fallbackMessage: "读取内测申请失败", + }); + return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : []; + }, + + async reviewApplication( + id: number, + action: "approve" | "reject", + reviewNote?: string, + ): Promise { + const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, { + method: "PATCH", + body: { action, reviewNote }, + maxRetries: 0, + fallbackMessage: "审核内测申请失败", + }); + return normalizeApplication(payload.application); + }, +}; diff --git a/src/api/generationConcurrency.ts b/src/api/generationConcurrency.ts index ed9df37..a4e3b56 100644 --- a/src/api/generationConcurrency.ts +++ b/src/api/generationConcurrency.ts @@ -7,10 +7,20 @@ interface GenerationSlot { createdAt: number; } -const MAX_ACTIVE_GENERATION_TASKS = 3; +const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3; const STALE_SLOT_MS = 6 * 60 * 60 * 1000; const activeSlots = new Map(); +let userMaxConcurrency: number | null = null; + +export function setUserMaxConcurrency(limit: number | null | undefined): void { + userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null; +} + +function getEffectiveLimit(): number { + return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS; +} + export function getGenerationUserKey(userId?: string | number | null): string { return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId); } @@ -39,8 +49,9 @@ export function claimGenerationSlot(input: { }): () => void { pruneStaleSlots(); const activeCount = getActiveGenerationTaskCount(input.userKey); - if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) { - throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。"); + const effectiveLimit = getEffectiveLimit(); + if (activeCount >= effectiveLimit) { + throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`); } const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index 777d46e..d86109a 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -434,6 +434,7 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null { candidate.enterpriseBalance ?? candidate.enterprise_balance, ), + maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency), activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages), }; } @@ -480,7 +481,7 @@ function migrateLegacyWorkflowData(old: Record, wrapper: Record description: String(wrapper.description || ""), source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank", settings: { - model: String(isRecord(old.settings) ? old.settings.model || "Nano Banana Pro" : "Nano Banana Pro"), + model: String(isRecord(old.settings) ? old.settings.model || "omni-水果 Pro" : "omni-水果 Pro"), ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"), duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"), resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"), diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts index ae702ec..3084581 100644 --- a/src/api/taskSubscription.ts +++ b/src/api/taskSubscription.ts @@ -44,7 +44,6 @@ export function waitForTask( let settled = false; let cleanup: (() => void) | null = null; let timeoutId: ReturnType | null = null; - let sseConnected = false; let fallbackTimerId: ReturnType | null = null; let lastProgress = 0; let lastProgressAt = startedAt; @@ -83,10 +82,9 @@ export function waitForTask( }; cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); - sseConnected = true; fallbackTimerId = setTimeout(() => { - if (settled || !sseConnected) return; + if (settled) return; if (cleanup) cleanup(); startPolling(); }, 5000); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 27f6d03..a130343 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -7,6 +7,7 @@ import { ossAssets } from "../data/ossAssets"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; import NotificationCenter from "./NotificationCenter"; +import BetaApplicationModal from "./BetaApplicationModal"; import { AnimatedPanel } from "./AnimatedPanel"; import AdminMonitor from "./AdminMonitor"; import CookieConsentBanner from "./CookieConsentBanner"; @@ -63,6 +64,12 @@ function formatBalance(cents: number): string { return `${value.toFixed(2)} 积分`; } +function canReviewBetaApplications(session: WebUserSession | null): boolean { + const role = String(session?.user.role || "").trim().toLowerCase(); + const username = String(session?.user.username || "").trim().toLowerCase(); + return role === "admin" || username === "xqy1912"; +} + function AppShell({ activeView, navItems, @@ -85,6 +92,7 @@ function AppShell({ const [rechargeOpen, setRechargeOpen] = useState(false); const [RechargeModal, setRechargeModal] = useState(null); const [infoOpen, setInfoOpen] = useState(false); + const [betaOpen, setBetaOpen] = useState(false); const infoRef = useRef(null); const [openSubmenuKey, setOpenSubmenuKey] = useState(null); const [publicConfig, setPublicConfig] = useState({}); @@ -247,6 +255,7 @@ function AppShell({ const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分"; const showCommunityReview = canReviewCommunity(session); const showCommunityCaseAdd = canManageCommunityCases(session); + const showBetaApplicationReview = canReviewBetaApplications(session); return (
OmniAI
+ {session && ( ) : null} + {showBetaApplicationReview ? ( + + ) : null} {showCommunityCaseAdd ? ( <>
); } diff --git a/src/components/BetaApplicationModal.tsx b/src/components/BetaApplicationModal.tsx new file mode 100644 index 0000000..850dff2 --- /dev/null +++ b/src/components/BetaApplicationModal.tsx @@ -0,0 +1,343 @@ +import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons"; +import { useState } from "react"; +import { betaApplicationClient } from "../api/betaApplicationClient"; + +interface BetaApplicationModalProps { + open: boolean; + onClose: () => void; +} + +/* ── Form state ── */ +interface BetaFormData { + name: string; + phone: string; + wechat: string; + industry: string; + company: string; + city: string; + aiTools: string; + aiDuration: string; + aiTrack: string; + aiDirection: string[]; + weeklyUsage: string; + feedbackWilling: string; + wantFeature: string[]; + selfStatement: string; + signature: string; + agreeRules: boolean; +} + +const INITIAL_FORM: BetaFormData = { + name: "", + phone: "", + wechat: "", + industry: "", + company: "", + city: "", + aiTools: "", + aiDuration: "", + aiTrack: "", + aiDirection: [], + weeklyUsage: "", + feedbackWilling: "", + wantFeature: [], + selfStatement: "", + signature: "", + agreeRules: false, +}; + +/* ── Option groups (from the docx) ── */ +const AI_DURATION_OPTIONS = ["1年以内", "1-3年", "3-5年", "5年以上"]; +const AI_TRACK_OPTIONS = ["是,长期承接相关业务", "业余创作", "新手学习"]; +const AI_DIRECTION_OPTIONS = [ + "AI短剧批量制作", "漫剧剧情生成", "自媒体短视频", "电商图文及视频素材", + "MCN商业内容", "企业宣传视频", "个人兴趣创作", "其他", +]; +const WEEKLY_USAGE_OPTIONS = ["7次及以上", "1-3次", "空闲时间使用"]; +const FEEDBACK_OPTIONS = ["全力配合深度反馈", "简单体验留言", "仅使用不反馈"]; +const WANT_FEATURE_OPTIONS = [ + "一站式短剧漫剧完整AIGC工作流", "电商素材自动化创作流程", + "多模态智能中枢全能创作", "批量自动化创作流程", "全新未公开AI创作玩法", +]; + +/* ── Helper: single-select radio group ── */ +function RadioGroup({ + name, options, value, onChange, +}: { + name: string; + options: string[]; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +/* ── Helper: multi-select checkbox group ── */ +function CheckboxGroup({ + options, value, onChange, +}: { + options: string[]; + value: string[]; + onChange: (v: string[]) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +/* ── Helper: text field ── */ +function TextField({ + label, value, onChange, placeholder, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+ {label} + onChange(e.target.value)} + placeholder={placeholder ?? "请填写"} + /> +
+ ); +} + +const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { + const [form, setForm] = useState(INITIAL_FORM); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null); + + const update = (key: K, value: BetaFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + setMessage(null); + }; + + const close = () => { + if (submitting) return; + onClose(); + }; + + const validate = () => { + if (!form.name.trim()) return "请填写姓名 / 常用昵称"; + if (!form.phone.trim()) return "请填写联系手机号码"; + if (!form.wechat.trim()) return "请填写微信账号"; + if (!form.selfStatement.trim()) return "请填写申请自述"; + if (!form.signature.trim()) return "请填写申请人确认签字"; + if (!form.agreeRules) return "请先阅读并同意内测规则"; + return null; + }; + + const submit = async () => { + if (submitting) return; + const validationError = validate(); + if (validationError) { + setMessage({ tone: "error", text: validationError }); + return; + } + + setSubmitting(true); + setMessage(null); + try { + await betaApplicationClient.submit({ + ...form, + name: form.name.trim(), + phone: form.phone.trim(), + wechat: form.wechat.trim(), + industry: form.industry.trim(), + company: form.company.trim(), + city: form.city.trim(), + aiTools: form.aiTools.trim(), + aiDuration: form.aiDuration.trim(), + aiTrack: form.aiTrack.trim(), + weeklyUsage: form.weeklyUsage.trim(), + feedbackWilling: form.feedbackWilling.trim(), + selfStatement: form.selfStatement.trim(), + signature: form.signature.trim(), + }); + setForm(INITIAL_FORM); + setMessage({ tone: "success", text: "申请已提交,请留意站内通知。" }); + } catch (error) { + setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" }); + } finally { + setSubmitting(false); + } + }; + + if (!open) return null; + + return ( +
+ + + + {/* ── Body (scrollable document) ── */} +
+ + {/* 一、个人基础信息 */} +
+

一、个人基础信息

+
+ update("name", v)} /> + update("phone", v)} /> + update("wechat", v)} /> + update("industry", v)} /> + update("company", v)} /> + update("city", v)} /> +
+
+ + {/* 二、AI从业与使用经历 */} +
+

二、AI 从业与使用经历

+
+ update("aiTools", v)} placeholder="例如:Midjourney / Stable Diffusion / ChatGPT 等" /> +
+ AI 内容创作从业时长 + update("aiDuration", v)} /> +
+
+ 是否深耕 AI 短剧、漫剧、数字视频、电商赛道 + update("aiTrack", v)} /> +
+
+ 日常主要创作方向(可多选) + update("aiDirection", v)} /> +
+
+
+ + {/* 三、内测使用意向调研 */} +
+

三、内测使用意向调研

+
+
+ 每周可稳定登录使用内测平台次数 + update("weeklyUsage", v)} /> +
+
+ 是否愿意积极反馈产品 BUG、优化建议、功能需求 + update("feedbackWilling", v)} /> +
+
+ 本次最想体验 OmniAI 核心功能(可多选) + update("wantFeature", v)} /> +
+
+
+ + {/* 四、申请自述 */} +
+

四、申请自述 (必填)

+

请简述自身 AI 创作优势、业务需求,以及加入本次封闭内测的理由:

+