Merge branch 'master' into feat/home-responsive-polish-and-ui-refinements
|
After Width: | Height: | Size: 729 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 685 KiB |
@@ -8,6 +8,7 @@ import ToastContainer from "./components/toast/ToastContainer";
|
|||||||
import { toast } from "./components/toast/toastStore";
|
import { toast } from "./components/toast/toastStore";
|
||||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
import { aiGenerationClient } from "./api/aiGenerationClient";
|
||||||
import { keyServerClient } from "./api/keyServerClient";
|
import { keyServerClient } from "./api/keyServerClient";
|
||||||
|
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||||
import { notificationClient } from "./api/notificationClient";
|
import { notificationClient } from "./api/notificationClient";
|
||||||
import {
|
import {
|
||||||
SERVER_SESSION_REPLACED_EVENT,
|
SERVER_SESSION_REPLACED_EVENT,
|
||||||
@@ -32,6 +33,7 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi
|
|||||||
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
|
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
|
||||||
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
|
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
|
||||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
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 AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
|
||||||
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
||||||
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
||||||
@@ -108,6 +110,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"more",
|
"more",
|
||||||
"communityReview",
|
"communityReview",
|
||||||
"communityCaseAdd",
|
"communityCaseAdd",
|
||||||
|
"betaApplications",
|
||||||
"report",
|
"report",
|
||||||
"providerHealth",
|
"providerHealth",
|
||||||
"userAgreement",
|
"userAgreement",
|
||||||
@@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
|
|||||||
"community",
|
"community",
|
||||||
"communityReview",
|
"communityReview",
|
||||||
"communityCaseAdd",
|
"communityCaseAdd",
|
||||||
|
"betaApplications",
|
||||||
"assets",
|
"assets",
|
||||||
"ecommerce",
|
"ecommerce",
|
||||||
"ecommerceHub",
|
"ecommerceHub",
|
||||||
@@ -156,6 +160,8 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
|||||||
? "communityReview"
|
? "communityReview"
|
||||||
: rawView === "community-case-add"
|
: rawView === "community-case-add"
|
||||||
? "communityCaseAdd"
|
? "communityCaseAdd"
|
||||||
|
: rawView === "beta-applications" || rawView === "beta-application-review"
|
||||||
|
? "betaApplications"
|
||||||
: rawView;
|
: rawView;
|
||||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
||||||
}
|
}
|
||||||
@@ -198,7 +204,7 @@ function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCan
|
|||||||
description: payload.prompt || "从生成结果进入画布继续创作。",
|
description: payload.prompt || "从生成结果进入画布继续创作。",
|
||||||
source: "blank",
|
source: "blank",
|
||||||
settings: {
|
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",
|
ratio: payload.resultType === "video" ? "16:9" : "1:1",
|
||||||
duration: payload.resultType === "video" ? "6s" : "0s",
|
duration: payload.resultType === "video" ? "6s" : "0s",
|
||||||
resolution: payload.resultType === "video" ? "720p" : "2K",
|
resolution: payload.resultType === "video" ? "720p" : "2K",
|
||||||
@@ -369,6 +375,7 @@ function App() {
|
|||||||
|
|
||||||
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
||||||
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
|
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
|
||||||
|
const [onboardingActive, setOnboardingActive] = useState(false);
|
||||||
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
||||||
@@ -467,9 +474,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [session, setView, setWorkspaceExpanded]);
|
}, [session, setView, setWorkspaceExpanded]);
|
||||||
|
|
||||||
|
const handleStartOnboarding = useCallback(() => {
|
||||||
|
setOnboardingActive(true);
|
||||||
|
try { window.localStorage.setItem("omniai:onboarding", "1"); } catch {}
|
||||||
|
handleSetView("workbench");
|
||||||
|
}, [handleSetView]);
|
||||||
|
|
||||||
|
const handleEndOnboarding = useCallback(() => {
|
||||||
|
setOnboardingActive(false);
|
||||||
|
try { window.localStorage.removeItem("omniai:onboarding"); } catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
||||||
clearAllUserStorage();
|
clearAllUserStorage();
|
||||||
clearSessionState();
|
clearSessionState();
|
||||||
|
setUserMaxConcurrency(null);
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
setProjectsLoaded(true);
|
setProjectsLoaded(true);
|
||||||
setUsage(emptyUsageSummary);
|
setUsage(emptyUsageSummary);
|
||||||
@@ -578,6 +597,7 @@ function App() {
|
|||||||
const nextSession = await keyServerClient.getCurrentSession();
|
const nextSession = await keyServerClient.getCurrentSession();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
await hydrateAccountData(nextSession);
|
await hydrateAccountData(nextSession);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -610,6 +630,7 @@ function App() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (nextSession) {
|
if (nextSession) {
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
} else {
|
} else {
|
||||||
clearAuthenticatedState({ resetView: true });
|
clearAuthenticatedState({ resetView: true });
|
||||||
}
|
}
|
||||||
@@ -947,6 +968,7 @@ function App() {
|
|||||||
async (nextSession: WebUserSession) => {
|
async (nextSession: WebUserSession) => {
|
||||||
hideSessionReplaced();
|
hideSessionReplaced();
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||||
await hydrateAccountData(nextSession);
|
await hydrateAccountData(nextSession);
|
||||||
|
|
||||||
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
||||||
@@ -1010,7 +1032,7 @@ function App() {
|
|||||||
previewUrl: payload.resultUrl,
|
previewUrl: payload.resultUrl,
|
||||||
params: payload.resultType === "video"
|
params: payload.resultType === "video"
|
||||||
? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-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,
|
assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1302,12 +1324,16 @@ function App() {
|
|||||||
onOpenReview={() => handleSetView("communityReview")}
|
onOpenReview={() => handleSetView("communityReview")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "betaApplications":
|
||||||
|
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
|
||||||
case "workbench":
|
case "workbench":
|
||||||
return (
|
return (
|
||||||
<WorkbenchPage
|
<WorkbenchPage
|
||||||
key={`workbench-${workbenchResetToken}`}
|
key={`workbench-${workbenchResetToken}`}
|
||||||
isAuthenticated={Boolean(session)}
|
isAuthenticated={Boolean(session)}
|
||||||
session={session}
|
session={session}
|
||||||
|
onboarding={onboardingActive}
|
||||||
|
onEndOnboarding={handleEndOnboarding}
|
||||||
onRequireLogin={handleRequireTaskLogin}
|
onRequireLogin={handleRequireTaskLogin}
|
||||||
onOpenResultInCanvas={handleOpenResultInCanvas}
|
onOpenResultInCanvas={handleOpenResultInCanvas}
|
||||||
onRefreshUsage={refreshUsage}
|
onRefreshUsage={refreshUsage}
|
||||||
@@ -1318,6 +1344,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<HomePage
|
<HomePage
|
||||||
onOpenGenerate={() => handleSetView("workbench")}
|
onOpenGenerate={() => handleSetView("workbench")}
|
||||||
|
onStartOnboarding={handleStartOnboarding}
|
||||||
onOpenCanvas={() => handleSetView("canvas")}
|
onOpenCanvas={() => handleSetView("canvas")}
|
||||||
onOpenEcommerce={() => handleSetView("ecommerce")}
|
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||||
onOpenScriptReview={() => handleSetView("scriptTokens")}
|
onOpenScriptReview={() => handleSetView("scriptTokens")}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { serverRequest } from "./serverConnection";
|
||||||
|
|
||||||
|
export interface BetaApplicationInput {
|
||||||
|
name: string;
|
||||||
|
email: 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;
|
||||||
|
applicationDate: string;
|
||||||
|
agreeRules: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BetaApplicationStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export interface BetaApplicationItem extends BetaApplicationInput {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
username: string | null;
|
||||||
|
status: BetaApplicationStatus;
|
||||||
|
inviteCode: string | null;
|
||||||
|
reviewNote: string | null;
|
||||||
|
reviewedBy: number | null;
|
||||||
|
reviewerUsername: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetaApplicationSubmitResult {
|
||||||
|
id: number;
|
||||||
|
status: BetaApplicationStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNullableString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberOrNull(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
const next = Number(value);
|
||||||
|
return Number.isFinite(next) ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => 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<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
id: Number(item.id) || 0,
|
||||||
|
userId: readNumberOrNull(item.userId),
|
||||||
|
username: readNullableString(item.username),
|
||||||
|
name: readString(item.name),
|
||||||
|
email: readString(item.email),
|
||||||
|
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),
|
||||||
|
applicationDate: readString(item.applicationDate),
|
||||||
|
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<BetaApplicationSubmitResult> {
|
||||||
|
const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", {
|
||||||
|
method: "POST",
|
||||||
|
body: input,
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "提交内测申请失败",
|
||||||
|
});
|
||||||
|
return payload.application;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAdminApplications(status?: BetaApplicationStatus | ""): Promise<BetaApplicationItem[]> {
|
||||||
|
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<BetaApplicationItem> {
|
||||||
|
const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { action, reviewNote },
|
||||||
|
maxRetries: 0,
|
||||||
|
fallbackMessage: "审核内测申请失败",
|
||||||
|
});
|
||||||
|
return normalizeApplication(payload.application);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -7,10 +7,20 @@ interface GenerationSlot {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ACTIVE_GENERATION_TASKS = 3;
|
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||||
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
||||||
const activeSlots = new Map<string, GenerationSlot>();
|
const activeSlots = new Map<string, GenerationSlot>();
|
||||||
|
|
||||||
|
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 {
|
export function getGenerationUserKey(userId?: string | number | null): string {
|
||||||
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
||||||
}
|
}
|
||||||
@@ -39,8 +49,9 @@ export function claimGenerationSlot(input: {
|
|||||||
}): () => void {
|
}): () => void {
|
||||||
pruneStaleSlots();
|
pruneStaleSlots();
|
||||||
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
||||||
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
|
const effectiveLimit = getEffectiveLimit();
|
||||||
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
|
if (activeCount >= effectiveLimit) {
|
||||||
|
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
|||||||
candidate.enterpriseBalance ??
|
candidate.enterpriseBalance ??
|
||||||
candidate.enterprise_balance,
|
candidate.enterprise_balance,
|
||||||
),
|
),
|
||||||
|
maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency),
|
||||||
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -480,7 +481,7 @@ function migrateLegacyWorkflowData(old: Record<string, unknown>, wrapper: Record
|
|||||||
description: String(wrapper.description || ""),
|
description: String(wrapper.description || ""),
|
||||||
source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank",
|
source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank",
|
||||||
settings: {
|
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"),
|
ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"),
|
||||||
duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"),
|
duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"),
|
||||||
resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"),
|
resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"),
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
|
|||||||
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
|
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value,
|
value,
|
||||||
label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value),
|
label:
|
||||||
|
value === "wan2.7-image-pro"
|
||||||
|
? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro"
|
||||||
|
: label,
|
||||||
description: toStringValue(raw.description) || undefined,
|
description: toStringValue(raw.description) || undefined,
|
||||||
badge: toStringValue(raw.badge) || undefined,
|
badge: toStringValue(raw.badge) || undefined,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -248,6 +248,17 @@ function isNonAuthErrorCode(code: string | undefined): boolean {
|
|||||||
].includes(code);
|
].includes(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthFailureResponse(status: number, payload: unknown): boolean {
|
||||||
|
if (status === 401) return true;
|
||||||
|
if (status !== 403) return false;
|
||||||
|
|
||||||
|
const code = getPayloadCode(payload);
|
||||||
|
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
|
||||||
|
|
||||||
|
const message = getPayloadMessage(payload) || "";
|
||||||
|
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
||||||
if (status !== 401 && status !== 403) return;
|
if (status !== 401 && status !== 403) return;
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -263,6 +274,7 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
|
|||||||
// Non-auth 403 errors (enterprise model access, insufficient balance) must
|
// Non-auth 403 errors (enterprise model access, insufficient balance) must
|
||||||
// not trigger session expiry.
|
// not trigger session expiry.
|
||||||
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
|
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
|
||||||
|
if (!isAuthFailureResponse(status, payload)) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastSessionExpiredEventAt < 1500) return;
|
if (now - lastSessionExpiredEventAt < 1500) return;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function waitForTask(
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let cleanup: (() => void) | null = null;
|
let cleanup: (() => void) | null = null;
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sseConnected = false;
|
|
||||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastProgress = 0;
|
let lastProgress = 0;
|
||||||
let lastProgressAt = startedAt;
|
let lastProgressAt = startedAt;
|
||||||
@@ -83,10 +82,9 @@ export function waitForTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
||||||
sseConnected = true;
|
|
||||||
|
|
||||||
fallbackTimerId = setTimeout(() => {
|
fallbackTimerId = setTimeout(() => {
|
||||||
if (settled || !sseConnected) return;
|
if (settled) return;
|
||||||
if (cleanup) cleanup();
|
if (cleanup) cleanup();
|
||||||
startPolling();
|
startPolling();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ossAssets } from "../data/ossAssets";
|
|||||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
|
import BetaApplicationModal from "./BetaApplicationModal";
|
||||||
import { AnimatedPanel } from "./AnimatedPanel";
|
import { AnimatedPanel } from "./AnimatedPanel";
|
||||||
import AdminMonitor from "./AdminMonitor";
|
import AdminMonitor from "./AdminMonitor";
|
||||||
import CookieConsentBanner from "./CookieConsentBanner";
|
import CookieConsentBanner from "./CookieConsentBanner";
|
||||||
@@ -63,6 +64,12 @@ function formatBalance(cents: number): string {
|
|||||||
return `${value.toFixed(2)} 积分`;
|
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({
|
function AppShell({
|
||||||
activeView,
|
activeView,
|
||||||
navItems,
|
navItems,
|
||||||
@@ -85,6 +92,7 @@ function AppShell({
|
|||||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||||
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
|
const [betaOpen, setBetaOpen] = useState(false);
|
||||||
const infoRef = useRef<HTMLDivElement>(null);
|
const infoRef = useRef<HTMLDivElement>(null);
|
||||||
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||||
@@ -247,6 +255,7 @@ function AppShell({
|
|||||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||||
const showCommunityReview = canReviewCommunity(session);
|
const showCommunityReview = canReviewCommunity(session);
|
||||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||||
|
const showBetaApplicationReview = canReviewBetaApplications(session);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -343,6 +352,15 @@ function AppShell({
|
|||||||
<span className="brand-lockup__name">OmniAI</span>
|
<span className="brand-lockup__name">OmniAI</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="web-topbar__actions">
|
<div className="web-topbar__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="beta-apply-button"
|
||||||
|
title="内测申请"
|
||||||
|
aria-label="内测申请"
|
||||||
|
onClick={() => setBetaOpen(true)}
|
||||||
|
>
|
||||||
|
内测申请
|
||||||
|
</button>
|
||||||
{session && (
|
{session && (
|
||||||
<NotificationCenter
|
<NotificationCenter
|
||||||
items={notifications}
|
items={notifications}
|
||||||
@@ -475,6 +493,19 @@ function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showBetaApplicationReview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-popover__review-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileOpen(false);
|
||||||
|
onSelectView("betaApplications");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShellIcon name="check-circle" />
|
||||||
|
内测申请审核
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{showCommunityCaseAdd ? (
|
{showCommunityCaseAdd ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -502,7 +533,7 @@ function AppShell({
|
|||||||
{rechargeOpen && RechargeModal ? (
|
{rechargeOpen && RechargeModal ? (
|
||||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||||
) : null}
|
) : null}
|
||||||
<CookieConsentBanner />
|
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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;
|
||||||
|
email: 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;
|
||||||
|
applicationDate: string;
|
||||||
|
agreeRules: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM: BetaFormData = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
wechat: "",
|
||||||
|
industry: "",
|
||||||
|
company: "",
|
||||||
|
city: "",
|
||||||
|
aiTools: "",
|
||||||
|
aiDuration: "",
|
||||||
|
aiTrack: "",
|
||||||
|
aiDirection: [],
|
||||||
|
weeklyUsage: "",
|
||||||
|
feedbackWilling: "",
|
||||||
|
wantFeature: [],
|
||||||
|
selfStatement: "",
|
||||||
|
signature: "",
|
||||||
|
applicationDate: "",
|
||||||
|
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 (
|
||||||
|
<div className="beta-radio-group">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt} className="beta-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
checked={value === opt}
|
||||||
|
onChange={() => onChange(opt)}
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helper: multi-select checkbox group ── */
|
||||||
|
function CheckboxGroup({
|
||||||
|
options, value, onChange,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="beta-checkbox-group">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt} className="beta-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.includes(opt)}
|
||||||
|
onChange={() => {
|
||||||
|
if (value.includes(opt)) {
|
||||||
|
onChange(value.filter((item) => item !== opt));
|
||||||
|
} else {
|
||||||
|
onChange([...value, opt]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{opt}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helper: text field ── */
|
||||||
|
function TextField({
|
||||||
|
label, value, onChange, placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="beta-text-field">
|
||||||
|
<span className="beta-text-field__label">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="beta-text-field__input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder ?? "请填写"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
||||||
|
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
const update = <K extends keyof BetaFormData>(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 (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱";
|
||||||
|
if (!form.phone.trim()) return "请填写联系手机号码";
|
||||||
|
if (!form.wechat.trim()) return "请填写微信账号";
|
||||||
|
if (!form.selfStatement.trim()) return "请填写申请自述";
|
||||||
|
if (!form.signature.trim()) return "请填写申请人确认签字";
|
||||||
|
if (!form.applicationDate.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(),
|
||||||
|
email: form.email.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(),
|
||||||
|
applicationDate: form.applicationDate.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 (
|
||||||
|
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
|
||||||
|
<button type="button" className="beta-application-modal__backdrop" onClick={close} aria-label="关闭内测申请弹窗" />
|
||||||
|
|
||||||
|
<section className="beta-application-modal__panel">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="beta-modal-header">
|
||||||
|
<div className="beta-modal-header__left">
|
||||||
|
<ExperimentOutlined className="beta-modal-header__icon" />
|
||||||
|
<div>
|
||||||
|
<h2 id="beta-modal-title">OmniAI 内测体验官申请表</h2>
|
||||||
|
<p className="beta-modal-header__subtitle">封闭限量内测 · 仅限 <strong>30 人</strong> · 赠送 <strong>500 元等值 50,000 积分</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="beta-modal-header__close" onClick={close} aria-label="关闭" disabled={submitting}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Body (scrollable document) ── */}
|
||||||
|
<div className="beta-modal-body">
|
||||||
|
|
||||||
|
{/* 一、个人基础信息 */}
|
||||||
|
<section className="beta-doc-section">
|
||||||
|
<h3 className="beta-doc-section__title">一、个人基础信息</h3>
|
||||||
|
<div className="beta-doc-grid">
|
||||||
|
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
|
||||||
|
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
|
||||||
|
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
|
||||||
|
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
|
||||||
|
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
|
||||||
|
<TextField label="所属公司 / 机构" value={form.company} onChange={(v) => update("company", v)} />
|
||||||
|
<TextField label="所在城市" value={form.city} onChange={(v) => update("city", v)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 二、AI从业与使用经历 */}
|
||||||
|
<section className="beta-doc-section">
|
||||||
|
<h3 className="beta-doc-section__title">二、AI 从业与使用经历</h3>
|
||||||
|
<div className="beta-doc-grid">
|
||||||
|
<TextField label="日常常用 AI 创作工具有哪些" value={form.aiTools} onChange={(v) => update("aiTools", v)} placeholder="例如:Midjourney / Stable Diffusion / ChatGPT 等" />
|
||||||
|
<div className="beta-form-group">
|
||||||
|
<span className="beta-form-group__label">AI 内容创作从业时长</span>
|
||||||
|
<RadioGroup name="aiDuration" options={AI_DURATION_OPTIONS} value={form.aiDuration} onChange={(v) => update("aiDuration", v)} />
|
||||||
|
</div>
|
||||||
|
<div className="beta-form-group">
|
||||||
|
<span className="beta-form-group__label">是否深耕 AI 短剧、漫剧、数字视频、电商赛道</span>
|
||||||
|
<RadioGroup name="aiTrack" options={AI_TRACK_OPTIONS} value={form.aiTrack} onChange={(v) => update("aiTrack", v)} />
|
||||||
|
</div>
|
||||||
|
<div className="beta-form-group beta-form-group--full">
|
||||||
|
<span className="beta-form-group__label">日常主要创作方向(可多选)</span>
|
||||||
|
<CheckboxGroup options={AI_DIRECTION_OPTIONS} value={form.aiDirection} onChange={(v) => update("aiDirection", v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 三、内测使用意向调研 */}
|
||||||
|
<section className="beta-doc-section">
|
||||||
|
<h3 className="beta-doc-section__title">三、内测使用意向调研</h3>
|
||||||
|
<div className="beta-doc-grid">
|
||||||
|
<div className="beta-form-group">
|
||||||
|
<span className="beta-form-group__label">每周可稳定登录使用内测平台次数</span>
|
||||||
|
<RadioGroup name="weeklyUsage" options={WEEKLY_USAGE_OPTIONS} value={form.weeklyUsage} onChange={(v) => update("weeklyUsage", v)} />
|
||||||
|
</div>
|
||||||
|
<div className="beta-form-group">
|
||||||
|
<span className="beta-form-group__label">是否愿意积极反馈产品 BUG、优化建议、功能需求</span>
|
||||||
|
<RadioGroup name="feedback" options={FEEDBACK_OPTIONS} value={form.feedbackWilling} onChange={(v) => update("feedbackWilling", v)} />
|
||||||
|
</div>
|
||||||
|
<div className="beta-form-group beta-form-group--full">
|
||||||
|
<span className="beta-form-group__label">本次最想体验 OmniAI 核心功能(可多选)</span>
|
||||||
|
<CheckboxGroup options={WANT_FEATURE_OPTIONS} value={form.wantFeature} onChange={(v) => update("wantFeature", v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 四、申请自述 */}
|
||||||
|
<section className="beta-doc-section">
|
||||||
|
<h3 className="beta-doc-section__title">四、申请自述 <em className="beta-required">(必填)</em></h3>
|
||||||
|
<p className="beta-doc-section__desc">请简述自身 AI 创作优势、业务需求,以及加入本次封闭内测的理由:</p>
|
||||||
|
<textarea
|
||||||
|
className="beta-textarea"
|
||||||
|
value={form.selfStatement}
|
||||||
|
onChange={(e) => update("selfStatement", e.target.value)}
|
||||||
|
placeholder="请在此填写您的申请自述(必填)…"
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 五、内测规则知情同意书 */}
|
||||||
|
<section className="beta-doc-section">
|
||||||
|
<h3 className="beta-doc-section__title">五、内测规则知情同意书</h3>
|
||||||
|
<ol className="beta-rules-list">
|
||||||
|
<li>本次为封闭限量内测,仅限 <strong>30 人</strong>,按照资质匹配度 + 申请顺序筛选;</li>
|
||||||
|
<li>内测赠送 <strong>500 元等值 50,000 积分</strong>,仅限内测期间使用,不可提现、不可转让、不可兑换现金;</li>
|
||||||
|
<li>内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;</li>
|
||||||
|
<li>严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;</li>
|
||||||
|
<li>审核通过后,官方将在 <strong>48 小时</strong> 内通过预留邮箱发放内测码、登录权限及免费积分;</li>
|
||||||
|
<li>正式版上线后,优质内测体验官可享受专属永久优惠权限与平台荣誉称号。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<label className="beta-agree-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.agreeRules}
|
||||||
|
onChange={(e) => update("agreeRules", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>本人已完整阅读并同意以上全部内测规则,自愿遵守内测所有管理要求。</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="beta-doc-grid beta-doc-grid--two">
|
||||||
|
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
|
||||||
|
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<footer className="beta-modal-footer">
|
||||||
|
{message ? (
|
||||||
|
<p className={`beta-modal-footer__message beta-modal-footer__message--${message.tone}`} role="status">
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={close} disabled={submitting}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={() => void submit()} disabled={submitting}>
|
||||||
|
{submitting ? "提交中..." : "提交申请"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BetaApplicationModal;
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||||
|
import "../styles/components/onboarding.css";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TourPhaseId = "chat" | "image" | "video";
|
||||||
|
|
||||||
|
interface TooltipStep {
|
||||||
|
target: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Which side of the target to place the tooltip on (preferred). */
|
||||||
|
placement?: "top" | "bottom" | "left" | "right";
|
||||||
|
/** If true, this step requires the user to interact with the element to proceed. */
|
||||||
|
interactive?: boolean;
|
||||||
|
/** Shown as hint text when interactive. */
|
||||||
|
actionHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TourPhase {
|
||||||
|
id: TourPhaseId;
|
||||||
|
label: string;
|
||||||
|
steps: TooltipStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingTourProps {
|
||||||
|
active: boolean;
|
||||||
|
phase: TourPhaseId;
|
||||||
|
stepIndex: number;
|
||||||
|
onNext: (phase: TourPhaseId, stepIndex: number) => void;
|
||||||
|
onSkip: (phase: TourPhaseId) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tour definitions ────────────────────────────────────────
|
||||||
|
|
||||||
|
const PHASES: Record<TourPhaseId, TourPhase> = {
|
||||||
|
chat: {
|
||||||
|
id: "chat",
|
||||||
|
label: "对话模式",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
target: "onboarding-chat-upload",
|
||||||
|
title: "参考素材上传",
|
||||||
|
description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-chat-model",
|
||||||
|
title: "AI 模型选择",
|
||||||
|
description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-chat-speed",
|
||||||
|
title: "思考速度",
|
||||||
|
description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-chat-depth",
|
||||||
|
title: "推理深度",
|
||||||
|
description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-chat-input",
|
||||||
|
title: "提示词输入框",
|
||||||
|
description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。",
|
||||||
|
placement: "top",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-mode-selector",
|
||||||
|
title: "切换到图像生成模式",
|
||||||
|
description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
id: "image",
|
||||||
|
label: "图像生成",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
target: "onboarding-image-upload",
|
||||||
|
title: "参考图上传",
|
||||||
|
description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-image-model",
|
||||||
|
title: "图像模型选择",
|
||||||
|
description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-image-settings",
|
||||||
|
title: "比例与分辨率",
|
||||||
|
description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-image-grid",
|
||||||
|
title: "单图 / 多宫格模式",
|
||||||
|
description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-image-input",
|
||||||
|
title: "图像提示词",
|
||||||
|
description: "描述你想要的图像内容、风格和细节,越具体效果越好。",
|
||||||
|
placement: "top",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-mode-selector",
|
||||||
|
title: "切换到视频生成模式",
|
||||||
|
description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
id: "video",
|
||||||
|
label: "视频生成",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
target: "onboarding-video-upload",
|
||||||
|
title: "参考素材上传",
|
||||||
|
description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。",
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-model",
|
||||||
|
title: "视频模型选择",
|
||||||
|
description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-frame",
|
||||||
|
title: "生成方式:全能 / 首尾帧",
|
||||||
|
description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-ratio",
|
||||||
|
title: "视频画面比例",
|
||||||
|
description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-duration",
|
||||||
|
title: "视频时长设置",
|
||||||
|
description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-quality",
|
||||||
|
title: "分辨率与画质",
|
||||||
|
description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。",
|
||||||
|
placement: "bottom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "onboarding-video-generate",
|
||||||
|
title: "一切就绪,开始创作!",
|
||||||
|
description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!",
|
||||||
|
placement: "top",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Connector line calculation ──────────────────────────────
|
||||||
|
|
||||||
|
interface ConnectorPoints {
|
||||||
|
x1: number; y1: number; // tooltip edge center
|
||||||
|
x2: number; y2: number; // target edge center
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcConnector(
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
targetRect: DOMRect,
|
||||||
|
placement: TooltipStep["placement"],
|
||||||
|
): ConnectorPoints {
|
||||||
|
const tx = targetRect.left + targetRect.width / 2;
|
||||||
|
const ty = targetRect.top + targetRect.height / 2;
|
||||||
|
const tcx = tooltipRect.left + tooltipRect.width / 2;
|
||||||
|
const tcy = tooltipRect.top + tooltipRect.height / 2;
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case "top":
|
||||||
|
return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top };
|
||||||
|
case "bottom":
|
||||||
|
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||||
|
case "left":
|
||||||
|
return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty };
|
||||||
|
case "right":
|
||||||
|
return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty };
|
||||||
|
default:
|
||||||
|
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Placement engine ─────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PlacementResult {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
actualPlacement: TooltipStep["placement"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score a candidate — lower is better. Penalises covering the target or overflow. */
|
||||||
|
function scorePlacement(
|
||||||
|
left: number, top: number, tw: number, th: number,
|
||||||
|
targetRect: DOMRect, vw: number, vh: number,
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
// Overflow penalty
|
||||||
|
if (left < 0) score += Math.abs(left);
|
||||||
|
if (top < 0) score += Math.abs(top);
|
||||||
|
if (left + tw > vw) score += (left + tw - vw);
|
||||||
|
if (top + th > vh) score += (top + th - vh);
|
||||||
|
// Overlap with target penalty (avoid covering the highlighted element)
|
||||||
|
const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left));
|
||||||
|
const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top));
|
||||||
|
if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestPlacement(
|
||||||
|
targetRect: DOMRect, tw: number, th: number,
|
||||||
|
preferred: TooltipStep["placement"],
|
||||||
|
): PlacementResult {
|
||||||
|
const gap = 144;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const all: Array<TooltipStep["placement"]> = [
|
||||||
|
preferred ?? "bottom",
|
||||||
|
...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" };
|
||||||
|
let bestScore = Infinity;
|
||||||
|
|
||||||
|
for (const p of all) {
|
||||||
|
let left = 0, top = 0;
|
||||||
|
switch (p) {
|
||||||
|
case "bottom":
|
||||||
|
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||||
|
top = targetRect.bottom + gap;
|
||||||
|
break;
|
||||||
|
case "top":
|
||||||
|
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||||
|
top = targetRect.top - th - gap;
|
||||||
|
break;
|
||||||
|
case "right":
|
||||||
|
left = targetRect.right + gap;
|
||||||
|
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||||
|
break;
|
||||||
|
case "left":
|
||||||
|
left = targetRect.left - tw - gap;
|
||||||
|
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
left = Math.max(12, Math.min(left, vw - tw - 12));
|
||||||
|
top = Math.max(12, Math.min(top, vh - th - 12));
|
||||||
|
|
||||||
|
const s = scorePlacement(left, top, tw, th, targetRect, vw, vh);
|
||||||
|
if (s < bestScore) {
|
||||||
|
bestScore = s;
|
||||||
|
best = { left, top, actualPlacement: p };
|
||||||
|
}
|
||||||
|
if (s === 0) break; // perfect
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function OnboardingTour({
|
||||||
|
active, phase, stepIndex, onNext, onSkip, onDone,
|
||||||
|
}: OnboardingTourProps) {
|
||||||
|
const [pos, setPos] = useState<PlacementResult>({ left: 0, top: 0, actualPlacement: "bottom" });
|
||||||
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [connector, setConnector] = useState<ConnectorPoints | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevPhaseRef = useRef(phase);
|
||||||
|
const prevStepRef = useRef(stepIndex);
|
||||||
|
|
||||||
|
const phaseDef = PHASES[phase];
|
||||||
|
const currentStep = phaseDef?.steps[stepIndex];
|
||||||
|
const totalSteps = phaseDef?.steps.length ?? 0;
|
||||||
|
const isLastStep = stepIndex >= totalSteps - 1;
|
||||||
|
const isVideoLastStep = phase === "video" && isLastStep;
|
||||||
|
|
||||||
|
const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex;
|
||||||
|
prevPhaseRef.current = phase;
|
||||||
|
prevStepRef.current = stepIndex;
|
||||||
|
|
||||||
|
const recalc = useCallback(() => {
|
||||||
|
if (!currentStep) return;
|
||||||
|
const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null;
|
||||||
|
if (!el) return; // Will be retried by the polling loop
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setTargetRect(rect);
|
||||||
|
|
||||||
|
const tooltip = tooltipRef.current;
|
||||||
|
if (!tooltip) return;
|
||||||
|
const tr = tooltip.getBoundingClientRect();
|
||||||
|
const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement);
|
||||||
|
setPos(best);
|
||||||
|
|
||||||
|
// Recalculate tooltip rect after position update (use the same best pos)
|
||||||
|
const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height);
|
||||||
|
setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement));
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) { setVisible(false); return; }
|
||||||
|
const t = setTimeout(() => { setVisible(true); recalc(); }, 120);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [active, phase, stepIndex, recalc]);
|
||||||
|
|
||||||
|
// Reposition and retry when elements aren't ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !visible) return;
|
||||||
|
const h = () => recalc();
|
||||||
|
window.addEventListener("resize", h);
|
||||||
|
window.addEventListener("scroll", h, true);
|
||||||
|
const obs = new MutationObserver(h);
|
||||||
|
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||||
|
|
||||||
|
// Polling retry: keep looking for the target element if not found yet
|
||||||
|
let retryId: number | null = null;
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = () => {
|
||||||
|
recalc();
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 40) retryId = requestAnimationFrame(poll);
|
||||||
|
};
|
||||||
|
// Start polling after a short delay
|
||||||
|
const startTimer = setTimeout(() => { poll(); }, 200);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", h);
|
||||||
|
window.removeEventListener("scroll", h, true);
|
||||||
|
obs.disconnect();
|
||||||
|
clearTimeout(startTimer);
|
||||||
|
if (retryId !== null) cancelAnimationFrame(retryId);
|
||||||
|
};
|
||||||
|
}, [active, visible, recalc]);
|
||||||
|
|
||||||
|
// Animate in on step change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !visible || !stepChanged) return;
|
||||||
|
const el = tooltipRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove("onboarding-tooltip--pop");
|
||||||
|
void el.offsetWidth; // force reflow
|
||||||
|
el.classList.add("onboarding-tooltip--pop");
|
||||||
|
}, [active, visible, stepChanged, phase, stepIndex]);
|
||||||
|
|
||||||
|
if (!active || !currentStep) return null;
|
||||||
|
|
||||||
|
const connectorPath = connector
|
||||||
|
? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const arrowAngle = connector
|
||||||
|
? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const clipPath = targetRect
|
||||||
|
? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={`onboarding-root${visible ? " is-visible" : ""}`} aria-label="新手引导教程">
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="onboarding-overlay" style={{ clipPath, WebkitClipPath: clipPath }} />
|
||||||
|
|
||||||
|
{/* Spotlight ring */}
|
||||||
|
{targetRect && (
|
||||||
|
<div
|
||||||
|
className="onboarding-spotlight"
|
||||||
|
style={{
|
||||||
|
left: targetRect.left - 8,
|
||||||
|
top: targetRect.top - 8,
|
||||||
|
width: targetRect.width + 16,
|
||||||
|
height: targetRect.height + 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated pulse ring */}
|
||||||
|
<div className="onboarding-spotlight__pulse" />
|
||||||
|
<div className="onboarding-spotlight__pulse onboarding-spotlight__pulse--delay" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connector SVG line */}
|
||||||
|
{connector && (
|
||||||
|
<svg className="onboarding-connector" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ob-conn-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="var(--accent, #00ff88)" stopOpacity="0.2" />
|
||||||
|
<stop offset="100%" stopColor="var(--accent, #00ff88)" stopOpacity="0.9" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Animated dash line */}
|
||||||
|
<path
|
||||||
|
d={connectorPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent, #00ff88)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="8 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
className="onboarding-connector__path"
|
||||||
|
/>
|
||||||
|
{/* Arrow at target end */}
|
||||||
|
<circle
|
||||||
|
cx={connector.x2}
|
||||||
|
cy={connector.y2}
|
||||||
|
r="5"
|
||||||
|
fill="var(--accent, #00ff88)"
|
||||||
|
className="onboarding-connector__dot"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip card */}
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className={`onboarding-tooltip onboarding-tooltip--${pos.actualPlacement}`}
|
||||||
|
style={{ left: pos.left, top: pos.top }}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={currentStep.title}
|
||||||
|
>
|
||||||
|
{/* Arrow pointing toward target */}
|
||||||
|
<div
|
||||||
|
className={`onboarding-tooltip__arrow onboarding-tooltip__arrow--${pos.actualPlacement}`}
|
||||||
|
style={{ transform: `rotate(${arrowAngle}deg)` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="onboarding-tooltip__head">
|
||||||
|
<span className="onboarding-tooltip__phase-badge">{phaseDef.label}</span>
|
||||||
|
<span className="onboarding-tooltip__counter">
|
||||||
|
{stepIndex + 1} / {totalSteps}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<strong className="onboarding-tooltip__title">{currentStep.title}</strong>
|
||||||
|
<p className="onboarding-tooltip__desc">{currentStep.description}</p>
|
||||||
|
|
||||||
|
<div className="onboarding-tooltip__actions">
|
||||||
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost" onClick={onDone}>
|
||||||
|
<CloseOutlined /> 跳过教程
|
||||||
|
</button>
|
||||||
|
{stepIndex > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost"
|
||||||
|
onClick={() => onNext(phase, stepIndex - 1)}
|
||||||
|
>
|
||||||
|
<LeftOutlined /> 上一步
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isVideoLastStep ? (
|
||||||
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={onDone}>
|
||||||
|
开始使用 <RightOutlined />
|
||||||
|
</button>
|
||||||
|
) : isLastStep && phase !== "video" ? (
|
||||||
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onSkip(phase)}>
|
||||||
|
{phase === "chat" ? "进入图像生成" : "进入视频生成"} <RightOutlined />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onNext(phase, stepIndex + 1)}>
|
||||||
|
下一步 <RightOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom progress bar */}
|
||||||
|
<div className="onboarding-progress" aria-hidden="true">
|
||||||
|
{(["chat", "image", "video"] as TourPhaseId[]).map((p) => (
|
||||||
|
<div key={p} className="onboarding-progress__phase">
|
||||||
|
<div
|
||||||
|
className={`onboarding-progress__dot${p === phase ? " is-active" : ""}${
|
||||||
|
(["chat", "image", "video"].indexOf(p) < ["chat", "image", "video"].indexOf(phase)) ? " is-done" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{PHASES[p].label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "月付",
|
period: "月付",
|
||||||
price: "299 元 / 月",
|
price: "299 元 / 月",
|
||||||
grant: "每月赠送 10000 积分,30 天有效",
|
grant: "每月赠送 29900 积分,30 天有效",
|
||||||
comparisonLabel: "专业版基础权益",
|
comparisonLabel: "专业版基础权益",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
||||||
@@ -41,7 +41,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "季付",
|
period: "季付",
|
||||||
price: "897 元 / 季",
|
price: "897 元 / 季",
|
||||||
grant: "连续 3 个月按月发放 Pro 积分",
|
grant: "季度合计 89700 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比月付新增",
|
comparisonLabel: "相比月付新增",
|
||||||
badge: "季度",
|
badge: "季度",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
@@ -54,7 +54,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Pro",
|
subtitle: "Pro",
|
||||||
period: "年付",
|
period: "年付",
|
||||||
price: "1990 元 / 年",
|
price: "1990 元 / 年",
|
||||||
grant: "全年合计 140000 积分,默认按月分摊",
|
grant: "全年合计 199000 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比季付新增",
|
comparisonLabel: "相比季付新增",
|
||||||
badge: "年费优惠",
|
badge: "年费优惠",
|
||||||
icon: <CrownOutlined />,
|
icon: <CrownOutlined />,
|
||||||
@@ -67,7 +67,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "月付",
|
period: "月付",
|
||||||
price: "499 元 / 月",
|
price: "499 元 / 月",
|
||||||
grant: "每月赠送 2000 积分,30 天有效",
|
grant: "每月赠送 49900 积分,30 天有效",
|
||||||
comparisonLabel: "企业版基础权益",
|
comparisonLabel: "企业版基础权益",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
||||||
@@ -79,7 +79,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "季付",
|
period: "季付",
|
||||||
price: "1497 元 / 季",
|
price: "1497 元 / 季",
|
||||||
grant: "连续 3 个月按月发放企业版积分",
|
grant: "季度合计 149700 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比月付新增",
|
comparisonLabel: "相比月付新增",
|
||||||
badge: "季度",
|
badge: "季度",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
@@ -92,7 +92,7 @@ const membershipPlans: MembershipPlan[] = [
|
|||||||
subtitle: "Enterprise",
|
subtitle: "Enterprise",
|
||||||
period: "年付",
|
period: "年付",
|
||||||
price: "4990 元 / 年",
|
price: "4990 元 / 年",
|
||||||
grant: "全年合计 340000 积分,默认按月分摊",
|
grant: "全年合计 499000 积分,默认按月分摊",
|
||||||
comparisonLabel: "相比季付新增",
|
comparisonLabel: "相比季付新增",
|
||||||
badge: "企业年费",
|
badge: "企业年费",
|
||||||
icon: <RocketOutlined />,
|
icon: <RocketOutlined />,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
|
||||||
import "../../styles/pages/assets.css";
|
import "../../styles/pages/assets.css";
|
||||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
@@ -95,6 +95,17 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
|||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isUploadDragging, setIsUploadDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); };
|
||||||
|
const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); };
|
||||||
|
const handleUploadDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsUploadDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
void handleUploadFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
|||||||
placeholder="搜索资产..."
|
placeholder="搜索资产..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
|
onDragOver={handleUploadDragOver}
|
||||||
|
onDragLeave={handleUploadDragLeave}
|
||||||
|
onDrop={handleUploadDrop}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||||
{isUploading ? "上传中..." : "添加"}
|
{isUploading ? "上传中..." : "添加"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
|
||||||
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
|
import type { WebUserSession } from "../../types";
|
||||||
|
import "../../styles/pages/beta-applications.css";
|
||||||
|
|
||||||
|
interface BetaApplicationsPageProps {
|
||||||
|
session: WebUserSession | null;
|
||||||
|
onOpenLogin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = BetaApplicationStatus | "";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
|
||||||
|
{ value: "pending", label: "待审核" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "", label: "全部" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
|
||||||
|
pending: "待审核",
|
||||||
|
approved: "已通过",
|
||||||
|
rejected: "已驳回",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 formatDate(value?: string | null): string {
|
||||||
|
if (!value) return "暂无时间";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrEmpty(value?: string | null): string {
|
||||||
|
return value?.trim() || "未填写";
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinValues(values: string[]): string {
|
||||||
|
return values.length ? values.join("、") : "未选择";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
|
||||||
|
const allowed = canReviewBetaApplications(session);
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||||
|
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [reviewNote, setReviewNote] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedApplication = useMemo(
|
||||||
|
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
|
||||||
|
[applications, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!allowed) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const items = await betaApplicationClient.listAdminApplications(status);
|
||||||
|
setApplications(items);
|
||||||
|
setSelectedId((current) =>
|
||||||
|
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
|
||||||
|
);
|
||||||
|
} catch (loadError) {
|
||||||
|
setApplications([]);
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [allowed, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleDecision = async (action: "approve" | "reject") => {
|
||||||
|
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
|
||||||
|
setReviewNote("");
|
||||||
|
await load();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||||
|
<section className="beta-admin-access">
|
||||||
|
<LoginOutlined />
|
||||||
|
<h1>请登录审核账号</h1>
|
||||||
|
<p>内测申请审核仅开放给管理员和 xqy1912。</p>
|
||||||
|
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
||||||
|
</section>
|
||||||
|
</WorkspacePageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return (
|
||||||
|
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||||
|
<section className="beta-admin-access">
|
||||||
|
<FileSearchOutlined />
|
||||||
|
<h1>当前账号没有审核权限</h1>
|
||||||
|
<p>请切换到 admin 或 xqy1912 后再进入内测审核台。</p>
|
||||||
|
</section>
|
||||||
|
</WorkspacePageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||||
|
<div className="beta-admin-page__inner">
|
||||||
|
<section className="beta-admin-toolbar">
|
||||||
|
<div>
|
||||||
|
<span>内部审核台</span>
|
||||||
|
<h1>内测申请表</h1>
|
||||||
|
<p>查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => void load()} disabled={loading}>
|
||||||
|
<ReloadOutlined />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value || "all"}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={status === option.value}
|
||||||
|
className={status === option.value ? "is-active" : ""}
|
||||||
|
onClick={() => setStatus(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="beta-admin-error">{error}</p> : null}
|
||||||
|
|
||||||
|
<section className="beta-admin-layout">
|
||||||
|
<aside className="beta-admin-list" aria-label="内测申请列表">
|
||||||
|
{loading ? <div className="beta-admin-list__empty">正在加载申请...</div> : null}
|
||||||
|
{!loading && applications.length === 0 ? (
|
||||||
|
<div className="beta-admin-list__empty">暂无需要显示的申请</div>
|
||||||
|
) : null}
|
||||||
|
{applications.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
>
|
||||||
|
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
|
||||||
|
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
|
||||||
|
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{selectedApplication ? (
|
||||||
|
<article className="beta-admin-detail">
|
||||||
|
<header className="beta-admin-detail__header">
|
||||||
|
<div>
|
||||||
|
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
|
||||||
|
<h2>{selectedApplication.name || "未填写姓名"}</h2>
|
||||||
|
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
|
||||||
|
</div>
|
||||||
|
{selectedApplication.inviteCode ? (
|
||||||
|
<strong className="beta-admin-code">内测码:{selectedApplication.inviteCode}</strong>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="beta-admin-form-card">
|
||||||
|
<h3>一、个人基础信息</h3>
|
||||||
|
<div className="beta-admin-field-grid">
|
||||||
|
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
|
||||||
|
<DetailField label="接收内测码邮箱" value={valueOrEmpty(selectedApplication.email)} />
|
||||||
|
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
|
||||||
|
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
|
||||||
|
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
|
||||||
|
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
|
||||||
|
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
|
||||||
|
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
|
||||||
|
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="beta-admin-form-card">
|
||||||
|
<h3>二、AI 从业与使用经历</h3>
|
||||||
|
<div className="beta-admin-field-grid">
|
||||||
|
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
|
||||||
|
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
|
||||||
|
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
|
||||||
|
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="beta-admin-form-card">
|
||||||
|
<h3>三、内测使用意向调研</h3>
|
||||||
|
<div className="beta-admin-field-grid">
|
||||||
|
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
|
||||||
|
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
|
||||||
|
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="beta-admin-form-card">
|
||||||
|
<h3>四、申请自述与确认</h3>
|
||||||
|
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
|
||||||
|
<div className="beta-admin-field-grid">
|
||||||
|
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
|
||||||
|
<DetailField label="申请填写日期" value={valueOrEmpty(selectedApplication.applicationDate)} />
|
||||||
|
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
|
||||||
|
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
|
||||||
|
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedApplication.status !== "pending" ? (
|
||||||
|
<section className="beta-admin-form-card">
|
||||||
|
<h3>审核结果</h3>
|
||||||
|
<div className="beta-admin-field-grid">
|
||||||
|
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
|
||||||
|
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
|
||||||
|
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="beta-admin-review-box">
|
||||||
|
<label>
|
||||||
|
<span>审核备注</span>
|
||||||
|
<textarea
|
||||||
|
value={reviewNote}
|
||||||
|
onChange={(event) => setReviewNote(event.target.value)}
|
||||||
|
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="beta-admin-actions">
|
||||||
|
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
驳回并通知
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
通过并发放内测码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<div className="beta-admin-detail beta-admin-detail--empty">选择左侧申请查看表单详情</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</WorkspacePageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,13 +58,13 @@ export const defaultTextModelId = textModelOptions[0].id;
|
|||||||
|
|
||||||
// --- Image model options ---
|
// --- Image model options ---
|
||||||
export const imageModelOptions: CanvasOption[] = [
|
export const imageModelOptions: CanvasOption[] = [
|
||||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro · 0.20 积分" },
|
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const imageRatioOptions: CanvasOption[] = [
|
export const imageRatioOptions: CanvasOption[] = [
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ function CharacterMixPage({
|
|||||||
const abortRef = useRef(false);
|
const abortRef = useRef(false);
|
||||||
const taskIdRef = useRef<string | null>(null);
|
const taskIdRef = useRef<string | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||||
|
const characterInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -262,6 +265,23 @@ function CharacterMixPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
|
||||||
|
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
|
||||||
|
const handleCanvasDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsCanvasDragging(false);
|
||||||
|
handleDrop(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasClick = () => {
|
||||||
|
if (!characterPreview) {
|
||||||
|
characterInputRef.current?.click();
|
||||||
|
} else if (!videoPreview) {
|
||||||
|
videoInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
|
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
|
||||||
<header className="image-workbench-topbar">
|
<header className="image-workbench-topbar">
|
||||||
@@ -342,6 +362,7 @@ function CharacterMixPage({
|
|||||||
<div className="studio-panel__section-body">
|
<div className="studio-panel__section-body">
|
||||||
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||||
<input
|
<input
|
||||||
|
ref={characterInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -383,6 +404,7 @@ function CharacterMixPage({
|
|||||||
<div className="studio-panel__section-body">
|
<div className="studio-panel__section-body">
|
||||||
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||||
<input
|
<input
|
||||||
|
ref={videoInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -441,12 +463,21 @@ function CharacterMixPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-canvas-ghost">
|
<div
|
||||||
|
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onDragOver={handleCanvasDragOver}
|
||||||
|
onDragLeave={handleCanvasDragLeave}
|
||||||
|
onDrop={handleCanvasDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
|
||||||
|
>
|
||||||
<div className="studio-canvas-ghost__icon">
|
<div className="studio-canvas-ghost__icon">
|
||||||
<SwapOutlined />
|
<SwapOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-ghost__title">上传人物图与参考视频</div>
|
<div className="studio-canvas-ghost__title">上传人物图与参考视频</div>
|
||||||
<div className="studio-canvas-ghost__hint">将静态角色迁移到参考视频的动作与表情中。</div>
|
<div className="studio-canvas-ghost__hint">点击或拖拽上传;支持人物图片 (PNG/JPG) 和参考视频 (MP4/MOV/AVI)</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { communityClient } from "../../api/communityClient";
|
import { communityClient } from "../../api/communityClient";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
|
import "../../styles/pages/compliance.css";
|
||||||
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
|
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
|
||||||
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
|
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
|
||||||
import { canManageCommunityCases } from "./communityPermissions";
|
import { canManageCommunityCases } from "./communityPermissions";
|
||||||
@@ -72,6 +73,29 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
|||||||
const allowed = canManageCommunityCases(session);
|
const allowed = canManageCommunityCases(session);
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const workflowInputRef = useRef<HTMLInputElement | null>(null);
|
const workflowInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [isImageDragging, setIsImageDragging] = useState(false);
|
||||||
|
const [isWorkflowDragging, setIsWorkflowDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleImageDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsImageDragging(true); };
|
||||||
|
const handleImageDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsImageDragging(false); };
|
||||||
|
const handleImageDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsImageDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
void handleImageChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWorkflowDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsWorkflowDragging(true); };
|
||||||
|
const handleWorkflowDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsWorkflowDragging(false); };
|
||||||
|
const handleWorkflowDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsWorkflowDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
void handleWorkflowChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [target, setTarget] = useState<CaseTarget>("generation");
|
const [target, setTarget] = useState<CaseTarget>("generation");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -330,7 +354,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
|||||||
</label>
|
</label>
|
||||||
<div className="community-case-add-upload-row">
|
<div className="community-case-add-upload-row">
|
||||||
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
|
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
|
||||||
<button type="button" onClick={() => imageInputRef.current?.click()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isImageDragging ? "is-dragging" : ""}
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
onDragOver={handleImageDragOver}
|
||||||
|
onDragLeave={handleImageDragLeave}
|
||||||
|
onDrop={handleImageDrop}
|
||||||
|
>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
上传图片
|
上传图片
|
||||||
</button>
|
</button>
|
||||||
@@ -344,7 +375,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
|||||||
<>
|
<>
|
||||||
<div className="community-case-add-upload-row">
|
<div className="community-case-add-upload-row">
|
||||||
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
|
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
|
||||||
<button type="button" onClick={() => workflowInputRef.current?.click()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isWorkflowDragging ? "is-dragging" : ""}
|
||||||
|
onClick={() => workflowInputRef.current?.click()}
|
||||||
|
onDragOver={handleWorkflowDragOver}
|
||||||
|
onDragLeave={handleWorkflowDragLeave}
|
||||||
|
onDrop={handleWorkflowDrop}
|
||||||
|
>
|
||||||
<UploadOutlined />
|
<UploadOutlined />
|
||||||
上传 JSON
|
上传 JSON
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||||
import { reportClient, type AdminReportItem } from "../../api/reportClient";
|
import { reportClient, type AdminReportItem } from "../../api/reportClient";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
|
import "../../styles/pages/compliance.css";
|
||||||
import type { WebUserSession } from "../../types";
|
import type { WebUserSession } from "../../types";
|
||||||
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
|
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ function DigitalHumanPage({
|
|||||||
activeTaskIdRef.current = activeTaskId;
|
activeTaskIdRef.current = activeTaskId;
|
||||||
const keepaliveRestoredRef = useRef(false);
|
const keepaliveRestoredRef = useRef(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const canvasDragCounterRef = useRef(0);
|
||||||
|
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -171,6 +175,39 @@ function DigitalHumanPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
|
||||||
|
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
|
||||||
|
const handleCanvasDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsCanvasDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.type.startsWith("image/")) {
|
||||||
|
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||||
|
setImageName(file.name);
|
||||||
|
setImageFile(file);
|
||||||
|
setImagePreview(URL.createObjectURL(file));
|
||||||
|
pushDebugEntry("选择图片", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||||||
|
setNotice(`已拖放参考图 ${file.name}`);
|
||||||
|
} else if (file.type.startsWith("audio/")) {
|
||||||
|
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||||||
|
setAudioName(file.name);
|
||||||
|
setAudioFile(file);
|
||||||
|
setAudioPreview(URL.createObjectURL(file));
|
||||||
|
pushDebugEntry("选择音频", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||||||
|
setNotice(`已拖放音频 ${file.name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasClick = () => {
|
||||||
|
if (!imagePreview) {
|
||||||
|
imageInputRef.current?.click();
|
||||||
|
} else if (!audioPreview) {
|
||||||
|
audioInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadResult = async () => {
|
const handleDownloadResult = async () => {
|
||||||
if (!resultVideoUrl || isDownloadingResult) return;
|
if (!resultVideoUrl || isDownloadingResult) return;
|
||||||
setIsDownloadingResult(true);
|
setIsDownloadingResult(true);
|
||||||
@@ -463,6 +500,7 @@ function DigitalHumanPage({
|
|||||||
<div className="studio-panel__section-body">
|
<div className="studio-panel__section-body">
|
||||||
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||||
<input
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -501,6 +539,7 @@ function DigitalHumanPage({
|
|||||||
<div className="studio-panel__section-body">
|
<div className="studio-panel__section-body">
|
||||||
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||||
<input
|
<input
|
||||||
|
ref={audioInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -541,12 +580,21 @@ function DigitalHumanPage({
|
|||||||
<img src={imagePreview} alt="参考人像" />
|
<img src={imagePreview} alt="参考人像" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-canvas-ghost">
|
<div
|
||||||
|
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onDragOver={handleCanvasDragOver}
|
||||||
|
onDragLeave={handleCanvasDragLeave}
|
||||||
|
onDrop={handleCanvasDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
|
||||||
|
>
|
||||||
<div className="studio-canvas-ghost__icon">
|
<div className="studio-canvas-ghost__icon">
|
||||||
<CustomerServiceOutlined />
|
<CustomerServiceOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-ghost__title">上传参考人像与音频</div>
|
<div className="studio-canvas-ghost__title">上传参考人像与音频</div>
|
||||||
<div className="studio-canvas-ghost__hint">网页端首版只做本地预览,正式生成仍会继续走服务端队列。</div>
|
<div className="studio-canvas-ghost__hint">点击或拖拽上传;支持图片 (PNG/JPG/WEBP) 和音频 (MP3/WAV/M4A)</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -842,6 +842,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const skipInitialCloneAutoSaveRef = useRef(true);
|
const skipInitialCloneAutoSaveRef = useRef(true);
|
||||||
const skipNextCloneAutoSaveRef = useRef(false);
|
const skipNextCloneAutoSaveRef = useRef(false);
|
||||||
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
|
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
|
||||||
|
useEffect(() => { setPreviewZoom(1); }, [activeTool]);
|
||||||
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
|
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
|
||||||
const [productSetPlatform, setProductSetPlatform] = useState(platformOptions[0]);
|
const [productSetPlatform, setProductSetPlatform] = useState(platformOptions[0]);
|
||||||
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
|
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
|
||||||
@@ -882,6 +883,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
||||||
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
||||||
const [previewZoom, setPreviewZoom] = useState(1);
|
const [previewZoom, setPreviewZoom] = useState(1);
|
||||||
|
|
||||||
|
const handlePreviewWheel = (event: React.WheelEvent<HTMLElement>) => {
|
||||||
|
if (!event.currentTarget) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const cursorX = event.clientX - rect.left;
|
||||||
|
const cursorY = event.clientY - rect.top;
|
||||||
|
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
|
||||||
|
|
||||||
|
const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta));
|
||||||
|
if (nextZoom === previewZoom) return;
|
||||||
|
|
||||||
|
const contentX = (cursorX + container.scrollLeft) / previewZoom;
|
||||||
|
const contentY = (cursorY + container.scrollTop) / previewZoom;
|
||||||
|
|
||||||
|
setPreviewZoom(nextZoom);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.scrollLeft = contentX * nextZoom - cursorX;
|
||||||
|
container.scrollTop = contentY * nextZoom - cursorY;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const [requirement, setRequirement] = useState("");
|
const [requirement, setRequirement] = useState("");
|
||||||
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
||||||
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
||||||
@@ -963,6 +988,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const selectedProductSetOutput =
|
const selectedProductSetOutput =
|
||||||
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
||||||
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
||||||
|
const cloneRequirementPlaceholder =
|
||||||
|
cloneOutput === "model"
|
||||||
|
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描写(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
|
||||||
|
: "建议包含以下信息,产品名称,核心卖点,期望场景,具体参数";
|
||||||
const productSetPreviewReady = productSetStatus === "done";
|
const productSetPreviewReady = productSetStatus === "done";
|
||||||
const cloneSetTotal = useMemo(
|
const cloneSetTotal = useMemo(
|
||||||
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
||||||
@@ -1909,7 +1938,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
age: cloneModelAge,
|
age: cloneModelAge,
|
||||||
ethnicity: cloneModelEthnicity,
|
ethnicity: cloneModelEthnicity,
|
||||||
body: cloneModelBody,
|
body: cloneModelBody,
|
||||||
appearance: cloneModelAppearance,
|
|
||||||
scenes: selectedCloneModelScenes,
|
scenes: selectedCloneModelScenes,
|
||||||
customScene: cloneModelCustomScene,
|
customScene: cloneModelCustomScene,
|
||||||
}
|
}
|
||||||
@@ -2200,7 +2228,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
cloneModelSelects={cloneModelSelects}
|
cloneModelSelects={cloneModelSelects}
|
||||||
openCloneModelSelect={openCloneModelSelect}
|
openCloneModelSelect={openCloneModelSelect}
|
||||||
cloneModelSelectDropUp={cloneModelSelectDropUp}
|
cloneModelSelectDropUp={cloneModelSelectDropUp}
|
||||||
cloneModelAppearance={cloneModelAppearance}
|
|
||||||
cloneVideoQuality={cloneVideoQuality}
|
cloneVideoQuality={cloneVideoQuality}
|
||||||
cloneVideoQualityOptions={cloneVideoQualityOptions}
|
cloneVideoQualityOptions={cloneVideoQualityOptions}
|
||||||
cloneVideoDuration={cloneVideoDuration}
|
cloneVideoDuration={cloneVideoDuration}
|
||||||
@@ -2232,7 +2259,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setCloneModelCustomScene={setCloneModelCustomScene}
|
setCloneModelCustomScene={setCloneModelCustomScene}
|
||||||
setOpenCloneModelSelect={setOpenCloneModelSelect}
|
setOpenCloneModelSelect={setOpenCloneModelSelect}
|
||||||
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
|
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
|
||||||
setCloneModelAppearance={setCloneModelAppearance}
|
|
||||||
setCloneVideoQuality={setCloneVideoQuality}
|
setCloneVideoQuality={setCloneVideoQuality}
|
||||||
setCloneVideoDuration={setCloneVideoDuration}
|
setCloneVideoDuration={setCloneVideoDuration}
|
||||||
clampCloneVideoDuration={clampCloneVideoDuration}
|
clampCloneVideoDuration={clampCloneVideoDuration}
|
||||||
@@ -2332,7 +2358,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setPreview = (
|
const setPreview = (
|
||||||
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览">
|
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览" onWheel={handlePreviewWheel}>
|
||||||
<div className="product-clone-preview__headline">
|
<div className="product-clone-preview__headline">
|
||||||
<h1>预览</h1>
|
<h1>预览</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -2400,7 +2426,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clonePreview = (
|
const clonePreview = (
|
||||||
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览">
|
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览" onWheel={handlePreviewWheel}>
|
||||||
<header className="clone-ai-preview-header">
|
<header className="clone-ai-preview-header">
|
||||||
<strong>预览</strong>
|
<strong>预览</strong>
|
||||||
<span>
|
<span>
|
||||||
@@ -2595,7 +2621,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
if (event.key === "Escape") setRequirementImageMentionQuery(null);
|
if (event.key === "Escape") setRequirementImageMentionQuery(null);
|
||||||
}}
|
}}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
|
placeholder={cloneRequirementPlaceholder}
|
||||||
/>
|
/>
|
||||||
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
|
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
|
||||||
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
|
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
|
||||||
@@ -2610,7 +2636,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const detailPreview = (
|
const detailPreview = (
|
||||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览">
|
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
||||||
<div className="product-clone-preview__headline">
|
<div className="product-clone-preview__headline">
|
||||||
<h1>A+/详情页</h1>
|
<h1>A+/详情页</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -2647,7 +2673,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tryOnPreview = (
|
const tryOnPreview = (
|
||||||
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览">
|
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览" onWheel={handlePreviewWheel}>
|
||||||
<div className="product-clone-preview__headline">
|
<div className="product-clone-preview__headline">
|
||||||
<h1>AI服饰穿戴</h1>
|
<h1>AI服饰穿戴</h1>
|
||||||
<p>上传服装图,定制专属模特,即刻生成多种场景不同姿势套图。</p>
|
<p>上传服装图,定制专属模特,即刻生成多种场景不同姿势套图。</p>
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ interface EcommerceClonePanelProps {
|
|||||||
cloneModelSelects: CloneModelSelectItem[];
|
cloneModelSelects: CloneModelSelectItem[];
|
||||||
openCloneModelSelect: CloneModelSelectKey | null;
|
openCloneModelSelect: CloneModelSelectKey | null;
|
||||||
cloneModelSelectDropUp: boolean;
|
cloneModelSelectDropUp: boolean;
|
||||||
cloneModelAppearance: string;
|
|
||||||
cloneVideoQuality: CloneVideoQualityKey;
|
cloneVideoQuality: CloneVideoQualityKey;
|
||||||
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||||
cloneVideoDuration: number;
|
cloneVideoDuration: number;
|
||||||
@@ -132,7 +131,6 @@ interface EcommerceClonePanelProps {
|
|||||||
setCloneModelCustomScene: (value: string) => void;
|
setCloneModelCustomScene: (value: string) => void;
|
||||||
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
||||||
setCloneModelSelectDropUp: (value: boolean) => void;
|
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||||
setCloneModelAppearance: (value: string) => void;
|
|
||||||
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||||
setCloneVideoDuration: (value: number) => void;
|
setCloneVideoDuration: (value: number) => void;
|
||||||
clampCloneVideoDuration: (value: number) => number;
|
clampCloneVideoDuration: (value: number) => number;
|
||||||
@@ -172,7 +170,6 @@ export default function EcommerceClonePanel({
|
|||||||
cloneModelSelects,
|
cloneModelSelects,
|
||||||
openCloneModelSelect,
|
openCloneModelSelect,
|
||||||
cloneModelSelectDropUp,
|
cloneModelSelectDropUp,
|
||||||
cloneModelAppearance,
|
|
||||||
cloneVideoQuality,
|
cloneVideoQuality,
|
||||||
cloneVideoQualityOptions,
|
cloneVideoQualityOptions,
|
||||||
cloneVideoDuration,
|
cloneVideoDuration,
|
||||||
@@ -204,7 +201,6 @@ export default function EcommerceClonePanel({
|
|||||||
setCloneModelCustomScene,
|
setCloneModelCustomScene,
|
||||||
setOpenCloneModelSelect,
|
setOpenCloneModelSelect,
|
||||||
setCloneModelSelectDropUp,
|
setCloneModelSelectDropUp,
|
||||||
setCloneModelAppearance,
|
|
||||||
setCloneVideoQuality,
|
setCloneVideoQuality,
|
||||||
setCloneVideoDuration,
|
setCloneVideoDuration,
|
||||||
clampCloneVideoDuration,
|
clampCloneVideoDuration,
|
||||||
@@ -668,14 +664,6 @@ export default function EcommerceClonePanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<label className="clone-ai-model-textarea">
|
|
||||||
<strong>外貌细节(可选)</strong>
|
|
||||||
<textarea
|
|
||||||
value={cloneModelAppearance}
|
|
||||||
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
|
||||||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -758,7 +746,7 @@ export default function EcommerceClonePanel({
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
||||||
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
|
{videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
|
||||||
</button>
|
</button>
|
||||||
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -774,7 +762,7 @@ export default function EcommerceClonePanel({
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
||||||
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
|
{videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
|
||||||
</button>
|
</button>
|
||||||
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||||
import type { ChangeEvent, RefObject } from "react";
|
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||||
|
|
||||||
interface EcommerceDetailPanelProps {
|
interface EcommerceDetailPanelProps {
|
||||||
@@ -59,6 +59,31 @@ export default function EcommerceDetailPanel({
|
|||||||
handleDetailGenerate,
|
handleDetailGenerate,
|
||||||
onCancelGenerate,
|
onCancelGenerate,
|
||||||
}: EcommerceDetailPanelProps) {
|
}: EcommerceDetailPanelProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleDetailUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="product-clone-panel__scroll">
|
<div className="product-clone-panel__scroll">
|
||||||
@@ -67,7 +92,14 @@ export default function EcommerceDetailPanel({
|
|||||||
商品原图
|
商品原图
|
||||||
<QuestionCircleOutlined />
|
<QuestionCircleOutlined />
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`product-clone-upload-zone product-detail-upload${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => detailInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<CloudUploadOutlined />
|
<CloudUploadOutlined />
|
||||||
上传图片
|
上传图片
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||||
import type { ChangeEvent, RefObject } from "react";
|
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||||
|
|
||||||
interface EcommerceTryOnPanelProps {
|
interface EcommerceTryOnPanelProps {
|
||||||
@@ -73,12 +73,44 @@ export default function EcommerceTryOnPanel({
|
|||||||
handleTryOnGenerate,
|
handleTryOnGenerate,
|
||||||
onCancelGenerate,
|
onCancelGenerate,
|
||||||
}: EcommerceTryOnPanelProps) {
|
}: EcommerceTryOnPanelProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleGarmentUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="product-clone-panel__scroll">
|
<div className="product-clone-panel__scroll">
|
||||||
<section className="product-clone-field">
|
<section className="product-clone-field">
|
||||||
<h2>服装图片</h2>
|
<h2>服装图片</h2>
|
||||||
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`product-clone-upload-zone product-try-on-upload${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => garmentInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<CloudUploadOutlined />
|
<CloudUploadOutlined />
|
||||||
服装图片
|
服装图片
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const {
|
|||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
onOpenGenerate: () => void;
|
onOpenGenerate: () => void;
|
||||||
|
onStartOnboarding?: () => void;
|
||||||
onOpenCanvas?: () => void;
|
onOpenCanvas?: () => void;
|
||||||
onOpenEcommerce: () => void;
|
onOpenEcommerce: () => void;
|
||||||
onOpenScriptReview?: () => void;
|
onOpenScriptReview?: () => void;
|
||||||
@@ -476,7 +477,7 @@ function EcommerceFeatureShowcase() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
|
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
|
||||||
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
|
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
|
||||||
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
|
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
|
||||||
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
|
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
|
||||||
@@ -628,7 +629,7 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="omni-home__actions" aria-label="首页入口">
|
<div className="omni-home__actions" aria-label="首页入口">
|
||||||
<button type="button" className="omni-home__entry" onClick={onOpenGenerate}>
|
<button type="button" className="omni-home__entry" onClick={onStartOnboarding || onOpenGenerate}>
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
<span>
|
<span>
|
||||||
快速生成
|
快速生成
|
||||||
|
|||||||
@@ -947,19 +947,22 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
|
||||||
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
|
|
||||||
onClick={() => inpaintFileInputRef.current?.click()}
|
onClick={() => inpaintFileInputRef.current?.click()}
|
||||||
onDragOver={handleInpaintDragOver}
|
onDragOver={handleInpaintDragOver}
|
||||||
onDragLeave={handleInpaintDragLeave}
|
onDragLeave={handleInpaintDragLeave}
|
||||||
onDrop={handleInpaintDrop}
|
onDrop={handleInpaintDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
|
||||||
>
|
>
|
||||||
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span>释放文件以上传</span></span> : null}
|
<div className="studio-canvas-ghost__icon">
|
||||||
<FileImageOutlined />
|
<FileImageOutlined />
|
||||||
<strong>拖拽或选择图片</strong>
|
</div>
|
||||||
<span>支持 PNG / JPG / WebP</span>
|
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||||
</button>
|
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传后使用画笔标注需要重绘的区域</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1389,12 +1392,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
|||||||
<img src={referenceImage} alt="参考图预览" />
|
<img src={referenceImage} alt="参考图预览" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-canvas-ghost">
|
<div
|
||||||
|
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||||
|
>
|
||||||
<div className="studio-canvas-ghost__icon">
|
<div className="studio-canvas-ghost__icon">
|
||||||
<PictureOutlined />
|
<PictureOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-ghost__title">上传参考图后在此预览</div>
|
<div className="studio-canvas-ghost__title">上传参考图后在此预览</div>
|
||||||
<div className="studio-canvas-ghost__hint">生成结果也会显示在这里</div>
|
<div className="studio-canvas-ghost__hint">点击或拖拽上传 (PNG / JPG / WebP),生成结果也会显示在这里</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import "../../styles/pages/profile.css";
|
import "../../styles/pages/profile.css";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
@@ -228,6 +228,28 @@ function ProfilePage({
|
|||||||
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
|
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
|
||||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [isBannerDragging, setIsBannerDragging] = useState(false);
|
||||||
|
const [isAvatarDragging, setIsAvatarDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleBannerDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsBannerDragging(true); };
|
||||||
|
const handleBannerDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsBannerDragging(false); };
|
||||||
|
const handleBannerDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsBannerDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleBannerUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsAvatarDragging(true); };
|
||||||
|
const handleAvatarDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsAvatarDragging(false); };
|
||||||
|
const handleAvatarDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsAvatarDragging(false);
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleAvatarUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [mode, setMode] = useState<WebAuthMode>("login");
|
const [mode, setMode] = useState<WebAuthMode>("login");
|
||||||
const [authTab, setAuthTab] = useState<AuthTab>("password");
|
const [authTab, setAuthTab] = useState<AuthTab>("password");
|
||||||
@@ -1047,8 +1069,11 @@ function ProfilePage({
|
|||||||
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
|
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
|
||||||
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
|
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
|
||||||
<header
|
<header
|
||||||
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
|
className={`profile-page__banner${bannerUrl ? " has-image" : ""}${isBannerDragging ? " is-dragging" : ""}`}
|
||||||
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
|
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
|
||||||
|
onDragOver={handleBannerDragOver}
|
||||||
|
onDragLeave={handleBannerDragLeave}
|
||||||
|
onDrop={handleBannerDrop}
|
||||||
>
|
>
|
||||||
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
|
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
|
||||||
<CameraOutlined />
|
<CameraOutlined />
|
||||||
@@ -1060,13 +1085,21 @@ function ProfilePage({
|
|||||||
<div className="profile-page__body">
|
<div className="profile-page__body">
|
||||||
<aside className="profile-page__sidebar">
|
<aside className="profile-page__sidebar">
|
||||||
<div className="profile-page__sidebar-head">
|
<div className="profile-page__sidebar-head">
|
||||||
<div className="profile-page__avatar-ring">
|
<div className={`profile-page__avatar-ring${isAvatarDragging ? " is-dragging" : ""}`}>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img className="profile-page__avatar" src={avatarUrl} alt="" />
|
<img className="profile-page__avatar" src={avatarUrl} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<span className="profile-page__avatar">{avatarLabel}</span>
|
<span className="profile-page__avatar">{avatarLabel}</span>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-page__avatar-edit"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
onDragOver={handleAvatarDragOver}
|
||||||
|
onDragLeave={handleAvatarDragLeave}
|
||||||
|
onDrop={handleAvatarDrop}
|
||||||
|
aria-label="更换头像"
|
||||||
|
>
|
||||||
<CameraOutlined />
|
<CameraOutlined />
|
||||||
</button>
|
</button>
|
||||||
<span className="profile-page__avatar-badge">
|
<span className="profile-page__avatar-badge">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
|
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
|
||||||
import { reportClient, type ReportInput } from "../../api/reportClient";
|
import { reportClient, type ReportInput } from "../../api/reportClient";
|
||||||
|
import "../../styles/pages/compliance.css";
|
||||||
|
|
||||||
type SubmitState = "idle" | "loading" | "success" | "error";
|
type SubmitState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
|||||||
@@ -601,11 +601,20 @@ function ResolutionUpscalePage({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-canvas-ghost">
|
<div
|
||||||
|
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||||
|
>
|
||||||
<div className="studio-canvas-ghost__icon">
|
<div className="studio-canvas-ghost__icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
|
<div className="studio-canvas-ghost__title">{mode === "image" ? "点击或拖拽上传图片" : "点击或拖拽上传视频"}</div>
|
||||||
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
|
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -679,14 +679,23 @@ function ScriptTokensPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : !result && (
|
) : !result && (
|
||||||
<div className="script-eval-v5-input-section">
|
<div className="script-eval-v5-input-section">
|
||||||
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
|
<div className={`script-eval-v5-illustration${isDragging ? " is-dragging" : ""}`} aria-label="上传剧本并开始评测">
|
||||||
<div
|
<div
|
||||||
className="script-eval-v5-illustration-hit"
|
className="script-eval-v5-illustration-hit"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onKeyDown={uploadKeyDown}
|
onKeyDown={uploadKeyDown}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
{isDragging && (
|
||||||
|
<div className="script-eval-v5-upload-drop-overlay">
|
||||||
|
<UploadOutlined />
|
||||||
|
<span>释放文件以上传</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="script-eval-v5-upload-card-icon">
|
<div className="script-eval-v5-upload-card-icon">
|
||||||
<ShellIcon name="file-text" />
|
<ShellIcon name="file-text" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -447,15 +447,19 @@ function SubtitleRemovalPage({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="studio-canvas-ghost"
|
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleFileDrop}
|
onDrop={handleFileDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||||
>
|
>
|
||||||
<div className="studio-canvas-ghost__icon">
|
<div className="studio-canvas-ghost__icon">
|
||||||
<VideoCameraOutlined />
|
<VideoCameraOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-canvas-ghost__title">拖拽或选择视频</div>
|
<div className="studio-canvas-ghost__title">点击或拖拽上传视频</div>
|
||||||
<div className="studio-canvas-ghost__hint">仅支持 MP4,最大 1GB,最高 1080P</div>
|
<div className="studio-canvas-ghost__hint">仅支持 MP4,最大 1GB,最高 1080P</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ScissorOutlined,
|
ScissorOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||||
import "../../styles/pages/more-tools.css";
|
import "../../styles/pages/more-tools.css";
|
||||||
import "../../styles/pages/image-workbench.css";
|
import "../../styles/pages/image-workbench.css";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
@@ -48,6 +48,7 @@ function WatermarkRemovalPage({
|
|||||||
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
|
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
|
||||||
const [activeTaskId, setActiveTaskId] = useState("");
|
const [activeTaskId, setActiveTaskId] = useState("");
|
||||||
const [taskProgress, setTaskProgress] = useState(0);
|
const [taskProgress, setTaskProgress] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isSavingAsset, setIsSavingAsset] = useState(false);
|
const [isSavingAsset, setIsSavingAsset] = useState(false);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -124,6 +125,10 @@ function WatermarkRemovalPage({
|
|||||||
setStatus(`已导入 ${file.name}`);
|
setStatus(`已导入 ${file.name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
|
||||||
|
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
|
||||||
|
const handleCanvasDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); handleFileDrop(e); };
|
||||||
|
|
||||||
const handleImportUrl = () => {
|
const handleImportUrl = () => {
|
||||||
const normalizedUrl = sourceUrl.trim();
|
const normalizedUrl = sourceUrl.trim();
|
||||||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||||||
@@ -403,17 +408,22 @@ function WatermarkRemovalPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||||
className="image-workbench-empty image-workbench-empty--button"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleFileDrop}
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleCanvasDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||||
>
|
>
|
||||||
<DeleteOutlined />
|
<div className="studio-canvas-ghost__icon">
|
||||||
<strong>拖拽或选择含水印图片</strong>
|
<DeleteOutlined />
|
||||||
<span>支持 PNG / JPG / WebP</span>
|
</div>
|
||||||
</button>
|
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||||
|
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传含水印图片后点击"开始去水印"</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ import { downloadResultAsset } from "./workbenchDownload";
|
|||||||
import { translateTaskError } from "../../utils/translateTaskError";
|
import { translateTaskError } from "../../utils/translateTaskError";
|
||||||
import {
|
import {
|
||||||
buildLocalTimeoutMessage,
|
buildLocalTimeoutMessage,
|
||||||
formatTextTokenUsage,
|
|
||||||
getTaskTimeoutPolicy,
|
getTaskTimeoutPolicy,
|
||||||
isTaskLocallyTimedOut,
|
isTaskLocallyTimedOut,
|
||||||
} from "../../utils/taskLifecycle";
|
} from "../../utils/taskLifecycle";
|
||||||
@@ -79,10 +78,12 @@ import {
|
|||||||
import { isViduModel } from "../../utils/viduRouting";
|
import { isViduModel } from "../../utils/viduRouting";
|
||||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||||
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||||
import {
|
import {
|
||||||
getImageQualityOptions,
|
getImageQualityOptions,
|
||||||
|
getImageQualityOptionsForContext,
|
||||||
getDefaultImageQuality,
|
getDefaultImageQuality,
|
||||||
|
getDefaultImageQualityForContext,
|
||||||
getVideoQualityOptions,
|
getVideoQualityOptions,
|
||||||
getDefaultVideoQuality,
|
getDefaultVideoQuality,
|
||||||
getVideoQualityLabel,
|
getVideoQualityLabel,
|
||||||
@@ -192,12 +193,15 @@ import {
|
|||||||
PromptPreviewLayer,
|
PromptPreviewLayer,
|
||||||
} from "./WorkbenchPromptPreview";
|
} from "./WorkbenchPromptPreview";
|
||||||
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
|
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
|
||||||
|
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
|
||||||
|
|
||||||
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
|
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
|
||||||
|
|
||||||
interface WorkbenchPageProps {
|
interface WorkbenchPageProps {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
session: WebUserSession | null;
|
session: WebUserSession | null;
|
||||||
|
onboarding?: boolean;
|
||||||
|
onEndOnboarding?: () => void;
|
||||||
onRequireLogin: (input: CreatePreviewTaskInput) => void;
|
onRequireLogin: (input: CreatePreviewTaskInput) => void;
|
||||||
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
|
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
|
||||||
onRefreshUsage?: () => void;
|
onRefreshUsage?: () => void;
|
||||||
@@ -221,9 +225,17 @@ const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
|
|||||||
video: <VideoCameraOutlined />,
|
video: <VideoCameraOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatCreditValue(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return "-";
|
||||||
|
if (value >= 100) return Math.round(value).toLocaleString("zh-CN");
|
||||||
|
return Number(value.toFixed(2)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
function WorkbenchPage({
|
function WorkbenchPage({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
session,
|
session,
|
||||||
|
onboarding,
|
||||||
|
onEndOnboarding,
|
||||||
onRequireLogin,
|
onRequireLogin,
|
||||||
onOpenResultInCanvas,
|
onOpenResultInCanvas,
|
||||||
onRefreshUsage,
|
onRefreshUsage,
|
||||||
@@ -257,7 +269,41 @@ function WorkbenchPage({
|
|||||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||||
const hasHandledInitialMessagesRef = useRef(false);
|
const hasHandledInitialMessagesRef = useRef(false);
|
||||||
|
|
||||||
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
|
// Onboarding signal — init from prop or localStorage
|
||||||
|
const [effectiveOnboarding, setEffectiveOnboarding] = useState(
|
||||||
|
() => onboarding || (() => { try { return window.localStorage.getItem("omniai:onboarding") === "1"; } catch { return false; } })(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track whether onboarding prop was ever true, to avoid overwriting localStorage-initiated true
|
||||||
|
const obWasActiveRef = useRef(onboarding);
|
||||||
|
useEffect(() => {
|
||||||
|
if (onboarding) {
|
||||||
|
obWasActiveRef.current = true;
|
||||||
|
setEffectiveOnboarding(true);
|
||||||
|
} else if (obWasActiveRef.current) {
|
||||||
|
// Only deactivate when prop transitions true→false (user dismissed)
|
||||||
|
setEffectiveOnboarding(false);
|
||||||
|
obWasActiveRef.current = false;
|
||||||
|
}
|
||||||
|
// If prop was never true, don't touch effectiveOnboarding (preserves localStorage init)
|
||||||
|
}, [onboarding]);
|
||||||
|
|
||||||
|
// Poll localStorage as a fallback (handles cases where prop isn't propagated)
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveOnboarding) return;
|
||||||
|
const check = () => {
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem("omniai:onboarding") === "1") {
|
||||||
|
setEffectiveOnboarding(true);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 200);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [effectiveOnboarding]);
|
||||||
|
|
||||||
|
const [activeMode, setActiveMode] = useState<WorkbenchMode>(() => effectiveOnboarding ? "chat" : "video");
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
|
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
|
||||||
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
|
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
|
||||||
@@ -287,6 +333,34 @@ function WorkbenchPage({
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||||
|
// ── Onboarding tour state ──────────────────────────────
|
||||||
|
const [tourPhase, setTourPhase] = useState<TourPhaseId>("chat");
|
||||||
|
const [tourStep, setTourStep] = useState(0);
|
||||||
|
|
||||||
|
// Sync activeMode with tour phase and keep home view during onboarding
|
||||||
|
useEffect(() => {
|
||||||
|
if (!effectiveOnboarding) return;
|
||||||
|
// Reset tour state for repeat runs
|
||||||
|
setTourPhase("chat");
|
||||||
|
setTourStep(0);
|
||||||
|
// Force "今天想生成什么?" home view — prevent conversation auto-select
|
||||||
|
skipConversationAutoSelectRef.current = true;
|
||||||
|
setWorkspaceStarted(false);
|
||||||
|
setActiveConversationId(null);
|
||||||
|
activeConversationIdRef.current = null;
|
||||||
|
persistActiveConversationId(null);
|
||||||
|
messagesRef.current = [];
|
||||||
|
setMessages([]);
|
||||||
|
}, [effectiveOnboarding]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveOnboarding) {
|
||||||
|
if (tourPhase === "chat") setActiveMode("chat");
|
||||||
|
else if (tourPhase === "image") setActiveMode("image");
|
||||||
|
else if (tourPhase === "video") setActiveMode("video");
|
||||||
|
}
|
||||||
|
}, [effectiveOnboarding, tourPhase]);
|
||||||
|
// ───────────────────────────────────────────────────────
|
||||||
const [, setGenerationProgress] = useState(0);
|
const [, setGenerationProgress] = useState(0);
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
||||||
@@ -420,6 +494,7 @@ function WorkbenchPage({
|
|||||||
const toolTheme = MODE_META[activeMode];
|
const toolTheme = MODE_META[activeMode];
|
||||||
const workbenchAccent = "#00ff88";
|
const workbenchAccent = "#00ff88";
|
||||||
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
|
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
|
||||||
|
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
|
||||||
const referenceCount = referenceItems.length;
|
const referenceCount = referenceItems.length;
|
||||||
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
|
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
|
||||||
const activeModelValue =
|
const activeModelValue =
|
||||||
@@ -464,11 +539,72 @@ function WorkbenchPage({
|
|||||||
setSidebarCollapsed(!hasSidebarRecords);
|
setSidebarCollapsed(!hasSidebarRecords);
|
||||||
}, [hasSidebarRecords]);
|
}, [hasSidebarRecords]);
|
||||||
|
|
||||||
const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]);
|
const hasImageReferences = activeMode === "image" && referenceItems.some((item) => item.kind === "image");
|
||||||
|
const isImageGridMode = activeMode === "image" && imageGridMode !== "single";
|
||||||
|
const imageQualityContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
hasReferenceImages: hasImageReferences,
|
||||||
|
isGridMode: isImageGridMode,
|
||||||
|
}),
|
||||||
|
[hasImageReferences, isImageGridMode],
|
||||||
|
);
|
||||||
|
const imageQualityOptions = useMemo(
|
||||||
|
() => getImageQualityOptionsForContext(imageModel, imageQualityContext),
|
||||||
|
[imageModel, imageQualityContext],
|
||||||
|
);
|
||||||
|
const imageGridModeOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
String(imageModel || "").toLowerCase().startsWith("wan2.7-")
|
||||||
|
? GRID_MODE_OPTIONS.filter((option) => option.value !== "grid-25")
|
||||||
|
: GRID_MODE_OPTIONS,
|
||||||
|
[imageModel],
|
||||||
|
);
|
||||||
const videoQualityOptions = getVideoQualityOptions(videoModel);
|
const videoQualityOptions = getVideoQualityOptions(videoModel);
|
||||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||||
|
|
||||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||||
|
const billingEstimate = useMemo(() => {
|
||||||
|
if (activeMode === "image") {
|
||||||
|
return {
|
||||||
|
label: "预计 20 积分",
|
||||||
|
title: `图片生成按任务计费:${activeModel},${imageSettingsSummary},预计 20 积分`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (activeMode === "video") {
|
||||||
|
try {
|
||||||
|
const durationSeconds = Math.max(1, Math.ceil(Number(videoDuration) || 1));
|
||||||
|
const credits = calculateEnterpriseVideoCredits({
|
||||||
|
model: activeModelValue,
|
||||||
|
resolution: videoQuality,
|
||||||
|
durationSeconds,
|
||||||
|
muted: false,
|
||||||
|
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||||
|
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
label: "计费以提交后为准",
|
||||||
|
title: "当前模型的预估计费暂不可用,实际扣费以服务端结算为准",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "按 Token 结算",
|
||||||
|
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
activeMode,
|
||||||
|
activeModel,
|
||||||
|
activeModelValue,
|
||||||
|
imageSettingsSummary,
|
||||||
|
referenceItems,
|
||||||
|
videoDuration,
|
||||||
|
videoQuality,
|
||||||
|
videoQualityLabel,
|
||||||
|
]);
|
||||||
const composerPlaceholder =
|
const composerPlaceholder =
|
||||||
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
|
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
|
||||||
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
|
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
|
||||||
@@ -1158,9 +1294,15 @@ function WorkbenchPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
|
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
|
||||||
setImageQuality(getDefaultImageQuality(imageModel));
|
setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext));
|
||||||
}
|
}
|
||||||
}, [imageModel, imageQuality, imageQualityOptions]);
|
}, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) {
|
||||||
|
setImageGridMode("single");
|
||||||
|
}
|
||||||
|
}, [imageGridMode, imageGridModeOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
|
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
|
||||||
@@ -1498,7 +1640,41 @@ function WorkbenchPage({
|
|||||||
setToolbarMenuId((current) => (current === menuId ? null : menuId));
|
setToolbarMenuId((current) => (current === menuId ? null : menuId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Onboarding tour helpers ────────────────────────────
|
||||||
|
const obTarget = (map: Partial<Record<TourPhaseId, string>>): string | undefined =>
|
||||||
|
effectiveOnboarding ? map[tourPhase] : undefined;
|
||||||
|
|
||||||
|
const handleTourNext = useCallback((_phase: TourPhaseId, stepIndex: number) => {
|
||||||
|
setTourStep(stepIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTourSkip = useCallback((phase: TourPhaseId) => {
|
||||||
|
const next: Record<TourPhaseId, TourPhaseId> = { chat: "image", image: "video", video: "video" };
|
||||||
|
const nextPhase = next[phase];
|
||||||
|
if (nextPhase === phase) {
|
||||||
|
onEndOnboarding?.();
|
||||||
|
} else {
|
||||||
|
setTourPhase(nextPhase);
|
||||||
|
setTourStep(0);
|
||||||
|
if (nextPhase === "image") setActiveMode("image");
|
||||||
|
else if (nextPhase === "video") setActiveMode("video");
|
||||||
|
}
|
||||||
|
}, [onEndOnboarding, setActiveMode]);
|
||||||
|
|
||||||
|
const handleTourDone = useCallback(() => {
|
||||||
|
setEffectiveOnboarding(false);
|
||||||
|
onEndOnboarding?.();
|
||||||
|
}, [onEndOnboarding]);
|
||||||
|
|
||||||
|
// Advance tour phase when user switches mode during onboarding
|
||||||
const handleModeChange = (mode: WorkbenchMode) => {
|
const handleModeChange = (mode: WorkbenchMode) => {
|
||||||
|
if (effectiveOnboarding) {
|
||||||
|
// Advance tour phase when switching to the next mode
|
||||||
|
if (tourPhase === "chat" && mode === "image") { setTourPhase("image"); setTourStep(0); }
|
||||||
|
else if (tourPhase === "image" && mode === "video") { setTourPhase("video"); setTourStep(0); }
|
||||||
|
// Block switching to other modes during guided tour
|
||||||
|
else if (mode !== tourPhase) return;
|
||||||
|
}
|
||||||
setActiveMode(mode);
|
setActiveMode(mode);
|
||||||
setToolbarMenuId(null);
|
setToolbarMenuId(null);
|
||||||
setReferencePreviewOpen(false);
|
setReferencePreviewOpen(false);
|
||||||
@@ -2585,7 +2761,15 @@ function WorkbenchPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
|
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
|
||||||
|
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
|
||||||
|
const promptIsEmpty = !inputValue.trim();
|
||||||
|
const sendDisabled = promptIsEmpty || generationLimitReached;
|
||||||
|
const sendButtonTitle = promptIsEmpty
|
||||||
|
? "输入内容后可发送"
|
||||||
|
: generationLimitReached
|
||||||
|
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
|
||||||
|
: billingEstimate.title;
|
||||||
|
|
||||||
const suggestedPrompts = [
|
const suggestedPrompts = [
|
||||||
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
|
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
|
||||||
@@ -2713,6 +2897,7 @@ function WorkbenchPage({
|
|||||||
className="wb-composer__ref-upload"
|
className="wb-composer__ref-upload"
|
||||||
onClick={handleReferenceUploadClick}
|
onClick={handleReferenceUploadClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
|
||||||
aria-label={`上传${referenceUploadLabel}`}
|
aria-label={`上传${referenceUploadLabel}`}
|
||||||
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
|
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
|
||||||
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
|
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
|
||||||
@@ -2772,6 +2957,7 @@ function WorkbenchPage({
|
|||||||
const renderComposerToolbar = (disabled = false, showStop = false) => (
|
const renderComposerToolbar = (disabled = false, showStop = false) => (
|
||||||
<div className="wb-composer__toolbar">
|
<div className="wb-composer__toolbar">
|
||||||
<div className="wb-composer__toolbar-left">
|
<div className="wb-composer__toolbar-left">
|
||||||
|
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="studio-mode"
|
chipId="studio-mode"
|
||||||
value={activeMode}
|
value={activeMode}
|
||||||
@@ -2784,8 +2970,10 @@ function WorkbenchPage({
|
|||||||
ariaLabel="工作台模式"
|
ariaLabel="工作台模式"
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
{activeMode === "chat" && (
|
{activeMode === "chat" && (
|
||||||
<>
|
<>
|
||||||
|
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="chat-model"
|
chipId="chat-model"
|
||||||
value={chatModel}
|
value={chatModel}
|
||||||
@@ -2798,6 +2986,8 @@ function WorkbenchPage({
|
|||||||
ariaLabel="对话模型"
|
ariaLabel="对话模型"
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="chat-speed"
|
chipId="chat-speed"
|
||||||
value={thinkingSpeed}
|
value={thinkingSpeed}
|
||||||
@@ -2810,6 +3000,8 @@ function WorkbenchPage({
|
|||||||
ariaLabel="思考速度"
|
ariaLabel="思考速度"
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="chat-depth"
|
chipId="chat-depth"
|
||||||
value={thinkingDepth}
|
value={thinkingDepth}
|
||||||
@@ -2822,10 +3014,12 @@ function WorkbenchPage({
|
|||||||
ariaLabel="思考深度"
|
ariaLabel="思考深度"
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeMode === "image" && (
|
{activeMode === "image" && (
|
||||||
<>
|
<>
|
||||||
|
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="image-model"
|
chipId="image-model"
|
||||||
value={imageModel}
|
value={imageModel}
|
||||||
@@ -2837,6 +3031,8 @@ function WorkbenchPage({
|
|||||||
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
|
||||||
<CompoundSelectChip
|
<CompoundSelectChip
|
||||||
chipId="image-settings"
|
chipId="image-settings"
|
||||||
summary={imageSettingsSummary}
|
summary={imageSettingsSummary}
|
||||||
@@ -2846,11 +3042,13 @@ function WorkbenchPage({
|
|||||||
onToggle={() => toggleToolbarMenu("image-settings")}
|
onToggle={() => toggleToolbarMenu("image-settings")}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
||||||
|
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="image-grid-mode"
|
chipId="image-grid-mode"
|
||||||
value={imageGridMode}
|
value={imageGridMode}
|
||||||
options={GRID_MODE_OPTIONS}
|
options={imageGridModeOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isOpen={toolbarMenuId === "image-grid-mode"}
|
isOpen={toolbarMenuId === "image-grid-mode"}
|
||||||
onToggle={() => toggleToolbarMenu("image-grid-mode")}
|
onToggle={() => toggleToolbarMenu("image-grid-mode")}
|
||||||
@@ -2858,11 +3056,13 @@ function WorkbenchPage({
|
|||||||
onChange={setImageGridMode}
|
onChange={setImageGridMode}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeMode === "video" && (
|
{activeMode === "video" && (
|
||||||
<>
|
<>
|
||||||
|
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="video-model"
|
chipId="video-model"
|
||||||
value={videoModel}
|
value={videoModel}
|
||||||
@@ -2874,6 +3074,8 @@ function WorkbenchPage({
|
|||||||
onChange={setVideoModel}
|
onChange={setVideoModel}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
|
||||||
<SelectChip
|
<SelectChip
|
||||||
chipId="video-mode"
|
chipId="video-mode"
|
||||||
value={videoFrameMode}
|
value={videoFrameMode}
|
||||||
@@ -2885,6 +3087,8 @@ function WorkbenchPage({
|
|||||||
onChange={setVideoFrameMode}
|
onChange={setVideoFrameMode}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
|
||||||
<CompoundSelectChip
|
<CompoundSelectChip
|
||||||
chipId="video-ratio"
|
chipId="video-ratio"
|
||||||
summary={videoRatio}
|
summary={videoRatio}
|
||||||
@@ -2894,6 +3098,8 @@ function WorkbenchPage({
|
|||||||
onToggle={() => toggleToolbarMenu("video-ratio")}
|
onToggle={() => toggleToolbarMenu("video-ratio")}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
|
||||||
<InlineOptionChip
|
<InlineOptionChip
|
||||||
chipId="video-duration"
|
chipId="video-duration"
|
||||||
value={videoDuration}
|
value={videoDuration}
|
||||||
@@ -2906,6 +3112,8 @@ function WorkbenchPage({
|
|||||||
onChange={setVideoDuration}
|
onChange={setVideoDuration}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
|
||||||
<InlineOptionChip
|
<InlineOptionChip
|
||||||
chipId="video-quality"
|
chipId="video-quality"
|
||||||
value={videoQuality}
|
value={videoQuality}
|
||||||
@@ -2918,14 +3126,21 @@ function WorkbenchPage({
|
|||||||
onChange={setVideoQuality}
|
onChange={setVideoQuality}
|
||||||
direction={dropdownDirection}
|
direction={dropdownDirection}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="wb-composer__toolbar-right">
|
<div className="wb-composer__toolbar-right">
|
||||||
|
<span className="wb-composer__billing-estimate" title={billingEstimate.title}>
|
||||||
|
{billingEstimate.label}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
|
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
|
||||||
disabled={sendDisabled || isGenerating}
|
disabled={sendDisabled || isGenerating}
|
||||||
|
title={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||||
|
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||||
|
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
|
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
|
||||||
mode: activeMode,
|
mode: activeMode,
|
||||||
@@ -3084,6 +3299,7 @@ function WorkbenchPage({
|
|||||||
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
|
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
|
||||||
placeholder={composerPlaceholder}
|
placeholder={composerPlaceholder}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
|
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||||
onChange={handlePromptChange}
|
onChange={handlePromptChange}
|
||||||
onSelect={handlePromptSelectionChange}
|
onSelect={handlePromptSelectionChange}
|
||||||
onKeyUp={handlePromptSelectionChange}
|
onKeyUp={handlePromptSelectionChange}
|
||||||
@@ -3149,6 +3365,14 @@ function WorkbenchPage({
|
|||||||
{renderMessagePreviewOverlay()}
|
{renderMessagePreviewOverlay()}
|
||||||
{renderPromptCaseOverlay()}
|
{renderPromptCaseOverlay()}
|
||||||
{renderDeleteDialog()}
|
{renderDeleteDialog()}
|
||||||
|
<OnboardingTour
|
||||||
|
active={Boolean(effectiveOnboarding)}
|
||||||
|
phase={tourPhase}
|
||||||
|
stepIndex={tourStep}
|
||||||
|
onNext={handleTourNext}
|
||||||
|
onSkip={handleTourSkip}
|
||||||
|
onDone={handleTourDone}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3231,11 +3455,6 @@ function WorkbenchPage({
|
|||||||
<span>{message.taskStatusLabel || generationStatus}</span>
|
<span>{message.taskStatusLabel || generationStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
|
|
||||||
<div className="ai-chat-task-billing-note">
|
|
||||||
{formatTextTokenUsage(message.taskUsage)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
message={message}
|
message={message}
|
||||||
@@ -3275,6 +3494,7 @@ function WorkbenchPage({
|
|||||||
placeholder={composerPlaceholder}
|
placeholder={composerPlaceholder}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||||
onChange={handlePromptChange}
|
onChange={handlePromptChange}
|
||||||
onSelect={handlePromptSelectionChange}
|
onSelect={handlePromptSelectionChange}
|
||||||
onKeyUp={handlePromptSelectionChange}
|
onKeyUp={handlePromptSelectionChange}
|
||||||
@@ -3322,6 +3542,15 @@ function WorkbenchPage({
|
|||||||
{showRechargeModal && RechargeModal ? (
|
{showRechargeModal && RechargeModal ? (
|
||||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<OnboardingTour
|
||||||
|
active={Boolean(effectiveOnboarding)}
|
||||||
|
phase={tourPhase}
|
||||||
|
stepIndex={tourStep}
|
||||||
|
onNext={handleTourNext}
|
||||||
|
onSkip={handleTourSkip}
|
||||||
|
onDone={handleTourDone}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
|
|||||||
|
|
||||||
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
|
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
|
||||||
{ value: "default", label: "默认" },
|
{ value: "default", label: "默认" },
|
||||||
{ value: "high", label: "高" },
|
{ value: "high", label: "思考速度:高" },
|
||||||
{ value: "ultra", label: "急速" },
|
{ value: "ultra", label: "思考速度:急速" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
|
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
|
||||||
{ value: "default", label: "默认" },
|
{ value: "default", label: "默认" },
|
||||||
{ value: "strong", label: "强" },
|
{ value: "strong", label: "推理深度:强" },
|
||||||
{ value: "extreme", label: "极限" },
|
{ value: "extreme", label: "推理深度:极限" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CHAT_NATURAL_SYSTEM_PROMPT = [
|
export const CHAT_NATURAL_SYSTEM_PROMPT = [
|
||||||
@@ -231,13 +231,13 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
|
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
|
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||||
{ value: "gpt-image-2", label: "GPT-Image-2" },
|
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP" },
|
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||||
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
|
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||||
{ value: "nano-banana-2", label: "Nano Banana 2" },
|
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||||
{ value: "nano-banana-fast", label: "Nano Banana" },
|
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
|
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const initialState: SessionState = {
|
|||||||
loginPromptOpen: false,
|
loginPromptOpen: false,
|
||||||
pendingAction: null,
|
pendingAction: null,
|
||||||
sessionReplacedOpen: false,
|
sessionReplacedOpen: false,
|
||||||
sessionReplacedMessage: '您的账号已在其他设备登录,此设备的登录状态已失效。',
|
sessionReplacedMessage: '当前账号已在其他设备登录,此设备的登录状态已失效。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSessionStore = create<SessionState & SessionActions>((set) => ({
|
export const useSessionStore = create<SessionState & SessionActions>((set) => ({
|
||||||
@@ -55,7 +55,7 @@ export const useSessionStore = create<SessionState & SessionActions>((set) => ({
|
|||||||
|
|
||||||
showSessionReplaced: (message) => set({
|
showSessionReplaced: (message) => set({
|
||||||
sessionReplacedOpen: true,
|
sessionReplacedOpen: true,
|
||||||
sessionReplacedMessage: message || '您的账号已在其他设备登录(最多同时 2 台设备),此设备的登录状态已失效。',
|
sessionReplacedMessage: message || '当前账号已在其他设备登录,此设备的登录状态已失效。',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
hideSessionReplaced: () => set({ sessionReplacedOpen: false }),
|
hideSessionReplaced: () => set({ sessionReplacedOpen: false }),
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/* ── Beta Application Modal ── */
|
||||||
|
/* Word-document style: paper-white panel, larger serif-ish typography, clear form fields */
|
||||||
|
|
||||||
|
.beta-application-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-application-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.58);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel: paper-like document ── */
|
||||||
|
.beta-application-modal__panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(800px, 94vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d9d5cf;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #faf8f4;
|
||||||
|
color: #1e1e1e;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 0 #e8e4dc,
|
||||||
|
0 4px 0 #d9d5cf,
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.2),
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.12);
|
||||||
|
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.beta-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 28px 36px 20px;
|
||||||
|
border-bottom: 2px solid #1e1e1e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f5f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #166534;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #1e1e1e;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__subtitle strong {
|
||||||
|
color: #166534;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__close {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d5cfc4;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f5f1ea;
|
||||||
|
color: #8c8276;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__close:hover {
|
||||||
|
background: #ede6da;
|
||||||
|
color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-header__close:disabled,
|
||||||
|
.beta-modal-footer__btn:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollable body ── */
|
||||||
|
.beta-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 40px;
|
||||||
|
background: #faf8f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Document sections ── */
|
||||||
|
.beta-doc-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding-bottom: 28px;
|
||||||
|
border-bottom: 1px dashed #d9d2c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-doc-section:last-of-type {
|
||||||
|
border-bottom: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-doc-section__title {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #1e1e1e;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-required {
|
||||||
|
color: #dc2626;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-doc-section__desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Single-column grid for form fields ── */
|
||||||
|
.beta-doc-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-doc-grid--two {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text field (Word underline style) ── */
|
||||||
|
.beta-text-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e1e;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__label::after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #c5beb2;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #1e1e1e;
|
||||||
|
line-height: 1.8;
|
||||||
|
transition: border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__input::placeholder {
|
||||||
|
color: #c4c4c4;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__input:focus {
|
||||||
|
border-bottom-color: #166534;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-text-field__input[readonly] {
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form group (spans full row when needed) ── */
|
||||||
|
.beta-form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-form-group--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-form-group__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-form-group__label::after {
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio group ── */
|
||||||
|
.beta-radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-radio {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
padding: 3px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-radio input[type="radio"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #b8b0a4;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-radio input[type="radio"]:checked {
|
||||||
|
border-color: #166534;
|
||||||
|
background: #166534;
|
||||||
|
box-shadow: inset 0 0 0 3px #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-radio:hover input[type="radio"] {
|
||||||
|
border-color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-radio span {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkbox group ── */
|
||||||
|
.beta-checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
padding: 3px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-checkbox input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #b8b0a4;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-checkbox input[type="checkbox"]:checked {
|
||||||
|
border-color: #166534;
|
||||||
|
background: #166534;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-size: 10px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-checkbox:hover input[type="checkbox"] {
|
||||||
|
border-color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-checkbox span {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Textarea ── */
|
||||||
|
.beta-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #c5beb2;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #1e1e1e;
|
||||||
|
line-height: 1.8;
|
||||||
|
transition: border-color 140ms ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-textarea::placeholder {
|
||||||
|
color: #c4c4c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-textarea:focus {
|
||||||
|
border-color: #166534;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 101, 52, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rules list ── */
|
||||||
|
.beta-rules-list {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-rules-list li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-rules-list li strong {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agreement checkbox row ── */
|
||||||
|
.beta-agree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-agree-row input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #b8b0a4;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-agree-row input[type="checkbox"]:checked {
|
||||||
|
border-color: #166534;
|
||||||
|
background: #166534;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-size: 12px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.beta-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 36px 20px;
|
||||||
|
border-top: 1px solid #e0dbd2;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f5f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message--success {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__message--error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn--secondary {
|
||||||
|
background: #e8e3d9;
|
||||||
|
color: #5c5348;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn--secondary:hover {
|
||||||
|
background: #dbd4c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn--primary {
|
||||||
|
background: #166534;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-modal-footer__btn--primary:hover {
|
||||||
|
background: #14532d;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/* ─── Onboarding Tour ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
font-family: Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-root.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Overlay ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.64);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: clip-path 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
will-change: clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* During interactive steps, let clicks pass through to dropdowns etc. */
|
||||||
|
.onboarding-overlay--passive {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip also lets clicks through during interactive steps, except for buttons */
|
||||||
|
.onboarding-tooltip--passive {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip--passive .onboarding-tooltip__btn {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Spotlight ring ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-spotlight {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10001;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid rgba(0, 255, 136, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 0 22px rgba(0, 255, 136, 0.18),
|
||||||
|
0 0 50px rgba(0, 255, 136, 0.06),
|
||||||
|
inset 0 0 0 1px rgba(0, 255, 136, 0.05);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
top 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-spotlight__pulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
animation: ob-pulse 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-spotlight__pulse--delay {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ob-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: scale(1.08); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Connector SVG ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-connector {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10002;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-connector__path {
|
||||||
|
animation: ob-dash 1.6s linear infinite;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ob-dash {
|
||||||
|
to { stroke-dashoffset: -24; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-connector__dot {
|
||||||
|
animation: ob-dot-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ob-dot-pulse {
|
||||||
|
0%, 100% { r: 4; opacity: 0.7; }
|
||||||
|
50% { r: 7; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tooltip card ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10003;
|
||||||
|
width: min(92vw, 360px);
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--bg-elevated, #1e1e1e);
|
||||||
|
border: 1px solid var(--border-subtle, #333);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
||||||
|
0 0 60px rgba(0, 255, 136, 0.04);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
top 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip--pop {
|
||||||
|
animation: ob-pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ob-pop-in {
|
||||||
|
from { opacity: 0; transform: scale(0.92) translateY(6px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip arrow — CSS triangle pointing toward target */
|
||||||
|
.onboarding-tooltip__arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none; /* replaced by SVG connector; fallback for simple cases */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tooltip head ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-tooltip__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__phase-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--accent-rgb, 0, 255, 136), 0.12);
|
||||||
|
color: var(--accent, #00ff88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__counter {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-muted, #777);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tooltip body ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-tooltip__title {
|
||||||
|
display: block;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-body, #e5e5e5);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--fg-muted, #999);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__action-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--accent, #00ff88);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: ob-hint-blink 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ob-hint-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tooltip actions ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-tooltip__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease, transform 0.12s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn--primary {
|
||||||
|
background: var(--accent, #00ff88);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn--primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-muted, #888);
|
||||||
|
border: 1px solid var(--border-subtle, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__btn--ghost:hover {
|
||||||
|
color: var(--fg-body, #e5e5e5);
|
||||||
|
border-color: var(--fg-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tooltip__wait-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted, #777);
|
||||||
|
font-style: italic;
|
||||||
|
animation: ob-hint-blink 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Progress bar (bottom-right) ──────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-progress {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10002;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__phase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-subtle, #444);
|
||||||
|
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__dot.is-active {
|
||||||
|
background: var(--accent, #00ff88);
|
||||||
|
transform: scale(1.4);
|
||||||
|
box-shadow: 0 0 12px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__dot.is-done {
|
||||||
|
background: rgba(var(--accent-rgb, 0, 255, 136), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__phase span {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--fg-muted, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress__phase .is-active + span,
|
||||||
|
.onboarding-progress__phase .is-done + span {
|
||||||
|
color: var(--fg-body, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.onboarding-tooltip {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
left: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
@import "./shell/app-shell.css";
|
@import "./shell/app-shell.css";
|
||||||
@import "./components/primitives.css";
|
@import "./components/primitives.css";
|
||||||
@import "./components/legacy-components.css";
|
@import "./components/legacy-components.css";
|
||||||
|
@import "./components/recharge-modal.css";
|
||||||
|
@import "./components/beta-application-modal.css";
|
||||||
|
@import "./components/dropzone.css";
|
||||||
|
@import "./components/skeleton.css";
|
||||||
@import "./components/toast.css";
|
@import "./components/toast.css";
|
||||||
@import "./components/page-transition.css";
|
@import "./components/page-transition.css";
|
||||||
@import "./components/motion.css";
|
@import "./components/motion.css";
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
.beta-admin-page__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
width: min(1180px, calc(100vw - 48px));
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar span {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar p {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button,
|
||||||
|
.beta-admin-status-tabs button,
|
||||||
|
.beta-admin-actions button,
|
||||||
|
.beta-admin-access button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar button:disabled,
|
||||||
|
.beta-admin-actions button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status-tabs button.is-active {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.45);
|
||||||
|
background: rgba(var(--accent-rgb), 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--error, #ef4444);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item.is-active {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.52);
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__item small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list__empty,
|
||||||
|
.beta-admin-detail--empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 180px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--pending {
|
||||||
|
background: rgba(245, 158, 11, 0.16);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--approved {
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-status--rejected {
|
||||||
|
background: rgba(239, 68, 68, 0.16);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header,
|
||||||
|
.beta-admin-form-card,
|
||||||
|
.beta-admin-review-box {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header h2 {
|
||||||
|
margin: 5px 0 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail__header p {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-form-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-form-card h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-field strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-statement {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box label span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-review-box textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
outline: none;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button:first-child {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions button:last-child {
|
||||||
|
border-color: rgba(16, 185, 129, 0.35);
|
||||||
|
background: rgba(16, 185, 129, 0.14);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 420px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access svg {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-access button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.38);
|
||||||
|
background: rgba(var(--accent-rgb), 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.beta-admin-page__inner {
|
||||||
|
width: min(100%, calc(100vw - 24px));
|
||||||
|
padding: 16px 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-toolbar,
|
||||||
|
.beta-admin-detail__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-detail {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.beta-admin-field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-admin-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1542,9 +1542,7 @@
|
|||||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
|
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
min-height: 0;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 1px solid #303540;
|
border: 1px solid #303540;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -1874,11 +1872,8 @@
|
|||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-module-panel {
|
.product-clone-page[data-tool="clone"] .clone-ai-module-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 0 0 272px;
|
flex: 0 0 auto;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 272px;
|
|
||||||
min-height: 0;
|
|
||||||
border: 1px solid #303540;
|
border: 1px solid #303540;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #1c1f26;
|
background: #1c1f26;
|
||||||
@@ -1906,25 +1901,6 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #3d4552 #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-track {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #3d4552;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-module-list button {
|
.product-clone-page[data-tool="clone"] .clone-ai-module-list button {
|
||||||
@@ -1981,11 +1957,8 @@
|
|||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-panel {
|
.product-clone-page[data-tool="clone"] .clone-ai-model-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 0 0 272px;
|
flex: 0 0 auto;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 272px;
|
|
||||||
min-height: 0;
|
|
||||||
border: 1px solid #303540;
|
border: 1px solid #303540;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #1c1f26;
|
background: #1c1f26;
|
||||||
@@ -2032,25 +2005,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll {
|
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll {
|
||||||
min-height: 0;
|
overflow: visible;
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #3d4552 #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-track {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #3d4552;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scenes,
|
.product-clone-page[data-tool="clone"] .clone-ai-model-scenes,
|
||||||
@@ -2223,16 +2178,12 @@
|
|||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #303540;
|
border: 1px solid #303540;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #22252d;
|
background: #22252d;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #3d4552 #171a20;
|
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
animation: clone-ai-model-select-pop 160ms cubic-bezier(0.2, 0.82, 0.2, 1) both;
|
animation: clone-ai-model-select-pop 160ms cubic-bezier(0.2, 0.82, 0.2, 1) both;
|
||||||
}
|
}
|
||||||
@@ -2244,20 +2195,6 @@
|
|||||||
animation-name: clone-ai-model-select-pop-up;
|
animation-name: clone-ai-model-select-pop-up;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-track {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #3d4552;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu button {
|
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2351,31 +2288,14 @@
|
|||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-video-panel {
|
.product-clone-page[data-tool="clone"] .clone-ai-video-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
overflow: visible;
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #303540;
|
border: 1px solid #303540;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #1c1f26;
|
background: #1c1f26;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #3d4552 #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-track {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #171a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #3d4552;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-video-section {
|
.product-clone-page[data-tool="clone"] .clone-ai-video-section {
|
||||||
@@ -8611,31 +8531,86 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
.clone-ai-video-outfit-upload {
|
.clone-ai-video-outfit-upload {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clone-ai-video-outfit-upload-btn {
|
.clone-ai-video-outfit-upload-btn {
|
||||||
padding: 7px 16px;
|
display: flex;
|
||||||
border: 1px solid var(--border-subtle);
|
width: 100%;
|
||||||
border-radius: 8px;
|
min-height: 118px;
|
||||||
background: var(--bg-inset);
|
flex-direction: column;
|
||||||
color: var(--fg-body);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1.5px dashed var(--ecm-line, var(--border-subtle));
|
||||||
|
border-radius: var(--ecm-radius-md, 14px);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 65%),
|
||||||
|
var(--ecm-inset, var(--bg-inset));
|
||||||
|
color: var(--ecm-text, var(--fg-body));
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 150ms, background 150ms;
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 150ms,
|
||||||
|
background 150ms,
|
||||||
|
color 150ms,
|
||||||
|
transform 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-video-outfit-upload-btn::before {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.26);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08);
|
||||||
|
color: var(--ecm-accent, var(--accent));
|
||||||
|
content: "+";
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clone-ai-video-outfit-upload-btn:hover {
|
.clone-ai-video-outfit-upload-btn:hover {
|
||||||
border-color: var(--border-default);
|
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.48);
|
||||||
background: var(--bg-hover);
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08), transparent 72%),
|
||||||
|
var(--ecm-inset-hover, var(--bg-hover));
|
||||||
|
color: var(--ecm-text, var(--fg-body));
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-video-outfit-upload-btn:active {
|
||||||
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clone-ai-video-outfit-info {
|
.clone-ai-video-outfit-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent);
|
font-weight: 850;
|
||||||
|
color: var(--ecm-accent, var(--accent));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-video-outfit-upload:has(.clone-ai-video-outfit-info) .clone-ai-video-outfit-upload-btn {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.38);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.075), transparent 74%),
|
||||||
|
var(--ecm-inset, var(--bg-inset));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
|
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
.omni-home__feature-page.is-script,
|
.omni-home__feature-page.is-script,
|
||||||
.omni-home__feature-page.is-model,
|
.omni-home__feature-page.is-model,
|
||||||
.omni-home__feature-page.is-ecommerce {
|
.omni-home__feature-page.is-ecommerce {
|
||||||
--home-showcase-page-pad-y: clamp(10px, 1.8vw, 24px);
|
--home-showcase-page-pad-y: clamp(4px, 0.8vw, 12px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -950,11 +950,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: inherit;
|
min-height: inherit;
|
||||||
gap: clamp(30px, 2.6cqw, 56px);
|
gap: clamp(30px, 2.6cqw, 56px);
|
||||||
padding: clamp(24px, 2.2cqw, 46px) clamp(36px, 3.7cqw, 72px);
|
padding: clamp(12px, 1.2cqw, 24px) clamp(24px, 2.4cqw, 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .left-panel,
|
.omni-home-ecommerce-matrix .left-panel,
|
||||||
@@ -966,10 +967,10 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .left-panel {
|
.omni-home-ecommerce-matrix .left-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 clamp(320px, 24cqw, 450px);
|
flex: 0 0 clamp(360px, 29cqw, 540px);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: clamp(16px, 1.1cqw, 26px);
|
gap: clamp(22px, 1.6cqw, 34px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,7 +983,7 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .hero-title {
|
.omni-home-ecommerce-matrix .hero-title {
|
||||||
color: var(--matrix-text-primary);
|
color: var(--matrix-text-primary);
|
||||||
font-size: clamp(40px, 3cqw, 58px);
|
font-size: clamp(48px, 3.6cqw, 72px);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
@@ -994,7 +995,7 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .hero-desc {
|
.omni-home-ecommerce-matrix .hero-desc {
|
||||||
color: var(--matrix-text-secondary);
|
color: var(--matrix-text-secondary);
|
||||||
font-size: clamp(16px, 1.2cqw, 22px);
|
font-size: clamp(19px, 1.4cqw, 26px);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@@ -1003,20 +1004,20 @@
|
|||||||
.omni-home-ecommerce-matrix .features {
|
.omni-home-ecommerce-matrix .features {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: clamp(12px, 0.95cqw, 18px);
|
gap: clamp(16px, 1.2cqw, 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .feature-item {
|
.omni-home-ecommerce-matrix .feature-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--matrix-border-subtle);
|
border: 1px solid var(--matrix-border-subtle);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
background: var(--matrix-card-surface);
|
background: var(--matrix-card-surface);
|
||||||
min-height: clamp(70px, 4.6cqw, 92px);
|
min-height: clamp(88px, 5.8cqw, 114px);
|
||||||
padding: clamp(16px, 1.2cqw, 24px);
|
padding: clamp(20px, 1.5cqw, 30px);
|
||||||
box-shadow: var(--matrix-shadow-card);
|
box-shadow: var(--matrix-shadow-card);
|
||||||
backdrop-filter: var(--matrix-glass-blur);
|
backdrop-filter: var(--matrix-glass-blur);
|
||||||
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
||||||
@@ -1049,15 +1050,15 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 clamp(44px, 2.8cqw, 56px);
|
flex: 0 0 clamp(50px, 3.4cqw, 64px);
|
||||||
width: clamp(44px, 2.8cqw, 56px);
|
width: clamp(50px, 3.4cqw, 64px);
|
||||||
height: clamp(44px, 2.8cqw, 56px);
|
height: clamp(50px, 3.4cqw, 64px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
background: linear-gradient(135deg, rgba(0, 255, 136, 0.15), rgba(168, 85, 247, 0.15));
|
background: linear-gradient(135deg, rgba(0, 255, 136, 0.15), rgba(168, 85, 247, 0.15));
|
||||||
font-size: clamp(20px, 1.4cqw, 28px);
|
font-size: clamp(24px, 1.6cqw, 32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .feature-text {
|
.omni-home-ecommerce-matrix .feature-text {
|
||||||
@@ -1067,15 +1068,15 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .feature-text h4 {
|
.omni-home-ecommerce-matrix .feature-text h4 {
|
||||||
color: var(--matrix-text-primary);
|
color: var(--matrix-text-primary);
|
||||||
font-size: clamp(18px, 1.2cqw, 24px);
|
font-size: clamp(20px, 1.4cqw, 26px);
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .feature-text p {
|
.omni-home-ecommerce-matrix .feature-text p {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
color: var(--matrix-text-dim);
|
color: var(--matrix-text-dim);
|
||||||
font-size: clamp(13px, 0.9cqw, 17px);
|
font-size: clamp(15px, 1cqw, 19px);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.1px;
|
letter-spacing: 0.1px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -1085,12 +1086,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-top: auto;
|
|
||||||
border: 1px solid var(--matrix-border-subtle);
|
border: 1px solid var(--matrix-border-subtle);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
background: var(--matrix-card-surface);
|
background: var(--matrix-card-surface);
|
||||||
min-height: clamp(72px, 4.6cqw, 92px);
|
min-height: clamp(88px, 5.8cqw, 114px);
|
||||||
padding: clamp(14px, 1.1cqw, 22px) clamp(16px, 1.4cqw, 26px);
|
padding: clamp(18px, 1.4cqw, 28px) clamp(20px, 1.7cqw, 34px);
|
||||||
box-shadow: var(--matrix-shadow-card);
|
box-shadow: var(--matrix-shadow-card);
|
||||||
backdrop-filter: var(--matrix-glass-blur);
|
backdrop-filter: var(--matrix-glass-blur);
|
||||||
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
||||||
@@ -1102,18 +1102,18 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .step-icon {
|
.omni-home-ecommerce-matrix .step-icon {
|
||||||
font-size: clamp(18px, 1.2cqw, 24px);
|
font-size: clamp(22px, 1.4cqw, 28px);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .step-label {
|
.omni-home-ecommerce-matrix .step-label {
|
||||||
color: var(--matrix-text-secondary);
|
color: var(--matrix-text-secondary);
|
||||||
font-size: clamp(12px, 0.82cqw, 16px);
|
font-size: clamp(14px, 0.95cqw, 18px);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1121,7 +1121,7 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .step-sub {
|
.omni-home-ecommerce-matrix .step-sub {
|
||||||
color: var(--matrix-text-dim);
|
color: var(--matrix-text-dim);
|
||||||
font-size: clamp(10px, 0.7cqw, 13px);
|
font-size: clamp(11px, 0.78cqw, 14px);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1156,22 +1156,22 @@
|
|||||||
|
|
||||||
.omni-home-ecommerce-matrix .center-panel {
|
.omni-home-ecommerce-matrix .center-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 clamp(310px, 22cqw, 430px);
|
flex: 0 0 clamp(350px, 26cqw, 500px);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .input-card {
|
.omni-home-ecommerce-matrix .input-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: min(100%, clamp(310px, 21cqw, 420px));
|
width: min(100%, clamp(350px, 24cqw, 480px));
|
||||||
border: 1px solid var(--matrix-border-default);
|
border: 1px solid var(--matrix-border-default);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
background: var(--matrix-card-elevated);
|
background: var(--matrix-card-elevated);
|
||||||
padding: clamp(20px, 1.45cqw, 30px);
|
padding: clamp(24px, 1.7cqw, 36px);
|
||||||
box-shadow: var(--matrix-shadow-elevated), 0 0 60px rgba(0, 255, 136, 0.08);
|
box-shadow: var(--matrix-shadow-elevated), 0 0 60px rgba(0, 255, 136, 0.08);
|
||||||
backdrop-filter: var(--matrix-glass-blur);
|
backdrop-filter: var(--matrix-glass-blur);
|
||||||
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
||||||
@@ -1203,12 +1203,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: clamp(16px, 1.1cqw, 24px);
|
margin-bottom: clamp(20px, 1.4cqw, 30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .input-card-label {
|
.omni-home-ecommerce-matrix .input-card-label {
|
||||||
color: var(--matrix-cyan);
|
color: var(--matrix-cyan);
|
||||||
font-size: clamp(14px, 1cqw, 18px);
|
font-size: clamp(16px, 1.15cqw, 22px);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 1.2px;
|
letter-spacing: 1.2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1220,7 +1220,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: var(--matrix-text-dim);
|
color: var(--matrix-text-dim);
|
||||||
font-size: clamp(12px, 0.9cqw, 16px);
|
font-size: clamp(13px, 0.95cqw, 17px);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1393,9 +1393,9 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: clamp(16px, 1.35cqw, 28px);
|
gap: clamp(22px, 1.6cqw, 34px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-left: clamp(34px, 3.4cqw, 78px);
|
padding-left: clamp(8px, 0.8cqw, 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .ai-node {
|
.omni-home-ecommerce-matrix .ai-node {
|
||||||
@@ -1403,13 +1403,13 @@
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: clamp(12px, 1cqw, 18px);
|
gap: clamp(14px, 1.15cqw, 22px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--matrix-border-default);
|
border: 1px solid var(--matrix-border-default);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
background: var(--matrix-card-highlight);
|
background: var(--matrix-card-highlight);
|
||||||
min-height: clamp(54px, 3.8cqw, 72px);
|
min-height: clamp(62px, 4.4cqw, 82px);
|
||||||
padding: 12px clamp(16px, 1.4cqw, 26px);
|
padding: 14px clamp(18px, 1.6cqw, 30px);
|
||||||
box-shadow: var(--matrix-shadow-card), var(--matrix-shadow-glow-purple);
|
box-shadow: var(--matrix-shadow-card), var(--matrix-shadow-glow-purple);
|
||||||
backdrop-filter: var(--matrix-glass-blur);
|
backdrop-filter: var(--matrix-glass-blur);
|
||||||
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
-webkit-backdrop-filter: var(--matrix-glass-blur);
|
||||||
@@ -1428,7 +1428,7 @@
|
|||||||
.omni-home-ecommerce-matrix .ai-node-title {
|
.omni-home-ecommerce-matrix .ai-node-title {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--matrix-cyan);
|
color: var(--matrix-cyan);
|
||||||
font-size: clamp(12px, 0.82cqw, 16px);
|
font-size: clamp(14px, 0.95cqw, 18px);
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
letter-spacing: 1.6px;
|
letter-spacing: 1.6px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1442,7 +1442,7 @@
|
|||||||
.omni-home-ecommerce-matrix .ai-node-list {
|
.omni-home-ecommerce-matrix .ai-node-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: clamp(8px, 0.75cqw, 14px);
|
gap: clamp(10px, 0.85cqw, 16px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1452,10 +1452,10 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
color: var(--matrix-text-secondary);
|
color: var(--matrix-text-secondary);
|
||||||
font-size: clamp(11px, 0.78cqw, 15px);
|
font-size: clamp(12px, 0.85cqw, 16px);
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
padding: 7px clamp(10px, 0.9cqw, 16px);
|
padding: 8px clamp(12px, 1cqw, 18px);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1463,28 +1463,28 @@
|
|||||||
.omni-home-ecommerce-matrix .output-group {
|
.omni-home-ecommerce-matrix .output-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: clamp(14px, 1.1cqw, 22px);
|
gap: clamp(16px, 1.3cqw, 26px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .output-label {
|
.omni-home-ecommerce-matrix .output-label {
|
||||||
flex: 0 0 clamp(96px, 6.5cqw, 132px);
|
flex: 0 0 clamp(72px, 5cqw, 100px);
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .output-label h4 {
|
.omni-home-ecommerce-matrix .output-label h4 {
|
||||||
color: var(--matrix-text-primary);
|
color: var(--matrix-text-primary);
|
||||||
font-size: clamp(18px, 1.3cqw, 25px);
|
font-size: clamp(20px, 1.45cqw, 28px);
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .output-label p {
|
.omni-home-ecommerce-matrix .output-label p {
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
color: var(--matrix-text-dim);
|
color: var(--matrix-text-dim);
|
||||||
font-size: clamp(11px, 0.82cqw, 15px);
|
font-size: clamp(12px, 0.9cqw, 16px);
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 0.8px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1501,14 +1501,14 @@
|
|||||||
.omni-home-ecommerce-matrix .output-cards {
|
.omni-home-ecommerce-matrix .output-cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: clamp(14px, 1.15cqw, 24px);
|
gap: clamp(10px, 0.85cqw, 18px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-ecommerce-matrix .output-card {
|
.omni-home-ecommerce-matrix .output-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: clamp(80px, 7cqw, 140px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--matrix-border-subtle);
|
border: 1px solid var(--matrix-border-subtle);
|
||||||
border-radius: var(--matrix-radius);
|
border-radius: var(--matrix-radius);
|
||||||
|
|||||||
@@ -11915,6 +11915,21 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wb-composer__billing-estimate {
|
||||||
|
max-width: 138px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 2px solid #111;
|
||||||
|
background: #fffbe8;
|
||||||
|
color: #111;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
box-shadow: 2px 2px 0 #111;
|
||||||
|
}
|
||||||
|
|
||||||
.wb-composer__send-primary {
|
.wb-composer__send-primary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1620,12 +1620,23 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-eval-v5-illustration:hover {
|
.script-eval-v5-illustration:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-eval-v5-illustration.is-dragging .script-eval-v5-illustration-hit {
|
||||||
|
background: var(--v5-green-deep);
|
||||||
|
outline: 2px dashed var(--v5-green);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-eval-v5-illustration .script-eval-v5-upload-drop-overlay {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.script-eval-v5-illustration-hit {
|
.script-eval-v5-illustration-hit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -299,6 +299,25 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s ease, outline 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-canvas-ghost:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.05);
|
||||||
|
outline: 1px dashed rgba(var(--accent-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-canvas-ghost:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-canvas-ghost.is-dragging {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.studio-canvas-ghost__icon {
|
.studio-canvas-ghost__icon {
|
||||||
|
|||||||
@@ -248,6 +248,43 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beta-apply-button {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
animation: beta-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-apply-button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beta-apply-button:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes beta-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.25); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0); }
|
||||||
|
}
|
||||||
|
|
||||||
.member-button {
|
.member-button {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1806,6 +1806,14 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.web-shell[data-ui-theme="dark-green"] .wb-composer__billing-estimate {
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--fg-body);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.web-shell[data-ui-theme="dark-green"] .wb-composer__send-primary:hover:not(:disabled) {
|
.web-shell[data-ui-theme="dark-green"] .wb-composer__send-primary:hover:not(:disabled) {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
color: var(--dg-button-text);
|
color: var(--dg-button-text);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type WebViewKey =
|
|||||||
| "dialogGenerator"
|
| "dialogGenerator"
|
||||||
| "communityReview"
|
| "communityReview"
|
||||||
| "communityCaseAdd"
|
| "communityCaseAdd"
|
||||||
|
| "betaApplications"
|
||||||
| "report"
|
| "report"
|
||||||
| "providerHealth"
|
| "providerHealth"
|
||||||
| "userAgreement"
|
| "userAgreement"
|
||||||
@@ -73,6 +74,7 @@ export interface WebUserSession extends WebApiResultMeta {
|
|||||||
enterpriseAdminUserId?: number | string | null;
|
enterpriseAdminUserId?: number | string | null;
|
||||||
balanceCents?: number;
|
balanceCents?: number;
|
||||||
enterpriseBalanceCents?: number;
|
enterpriseBalanceCents?: number;
|
||||||
|
maxConcurrency?: number;
|
||||||
activePackages?: Array<{
|
activePackages?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const ENTERPRISE_VIDEO_RESOLUTION_OPTIONS = [
|
|||||||
|
|
||||||
export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL;
|
export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL;
|
||||||
export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P";
|
export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P";
|
||||||
|
const CREDITS_PER_CNY = 100;
|
||||||
|
|
||||||
export interface EnterpriseVideoPricingInput {
|
export interface EnterpriseVideoPricingInput {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -74,11 +75,11 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (model.includes("vidu")) {
|
if (model.includes("vidu")) {
|
||||||
return resolution === "720P" ? 0.4 : 0.8;
|
return resolution === "720P" ? 0.6 : 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.includes("pixverse")) {
|
if (model.includes("pixverse")) {
|
||||||
return resolution === "720P" ? 0.4 : 0.8;
|
return resolution === "720P" ? 0.6 : 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.includes("kling")) {
|
if (model.includes("kling")) {
|
||||||
@@ -94,5 +95,5 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
|
|||||||
|
|
||||||
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
|
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
|
||||||
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
|
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
|
||||||
return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2));
|
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,30 @@ export function getImageQualityOptions(model: string): CanvasOption[] {
|
|||||||
: imageQualityOptions.filter((option) => option.value !== "4K");
|
: imageQualityOptions.filter((option) => option.value !== "4K");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getImageQualityOptionsForContext(
|
||||||
|
model: string,
|
||||||
|
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
|
||||||
|
): CanvasOption[] {
|
||||||
|
const options = getImageQualityOptions(model);
|
||||||
|
const shouldLimitTo2K =
|
||||||
|
String(model || "").toLowerCase() === "wan2.7-image-pro" &&
|
||||||
|
(context?.hasReferenceImages || context?.isGridMode);
|
||||||
|
return shouldLimitTo2K ? options.filter((option) => option.value !== "4K") : options;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultImageQuality(model: string): string {
|
export function getDefaultImageQuality(model: string): string {
|
||||||
const options = getImageQualityOptions(model);
|
const options = getImageQualityOptions(model);
|
||||||
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
|
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultImageQualityForContext(
|
||||||
|
model: string,
|
||||||
|
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
|
||||||
|
): string {
|
||||||
|
const options = getImageQualityOptionsForContext(model, context);
|
||||||
|
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Video quality ────────────────────────────────────────────────────────────
|
// ─── Video quality ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function normalizeVideoModel(model: string): string {
|
function normalizeVideoModel(model: string): string {
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ export interface TextTokenUsage {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
|
const CREDITS_PER_CNY = 100;
|
||||||
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
|
|
||||||
|
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
|
||||||
|
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
|
||||||
|
|
||||||
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
|
||||||
submitTimeoutMs: 90_000,
|
submitTimeoutMs: 90_000,
|
||||||
@@ -151,7 +153,7 @@ export function estimateTextTokenCredits(usage: TextTokenUsage): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
|
||||||
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
|
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
|
||||||
if (!usage) return rule;
|
if (!usage) return rule;
|
||||||
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
|
||||||
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
|
||||||
|
|||||||