Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f50a5b1f77 | |||
| 2509925644 | |||
| 4562243fd7 | |||
| 52677e33f1 | |||
| 4d7bec0dd7 | |||
| 4a298d205b | |||
| f322679d4a | |||
| af5081d382 | |||
| eb095bbe98 | |||
| d535d0d74a | |||
| 6ed65ca3ee | |||
| 1e756808c1 | |||
| 1049fa3218 | |||
| 6f54ad92c0 | |||
| 9b7e708f85 |
@@ -0,0 +1,30 @@
|
||||
name: Web Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- "codex/**"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify
|
||||
run: npm run verify
|
||||
@@ -0,0 +1,10 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
tmp
|
||||
.codex-tmp
|
||||
.codex-logs
|
||||
screenshots
|
||||
*.log
|
||||
*.tmp
|
||||
package-lock.json
|
||||
@@ -0,0 +1,77 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"tmp/**",
|
||||
".codex-tmp/**",
|
||||
".codex-logs/**",
|
||||
"screenshots/**",
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"vite-*.log",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"scripts/**/*.mjs",
|
||||
"vite.config.ts",
|
||||
"eslint.config.js",
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
AbortController: "readonly",
|
||||
AbortSignal: "readonly",
|
||||
Blob: "readonly",
|
||||
clearInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
console: "readonly",
|
||||
crypto: "readonly",
|
||||
document: "readonly",
|
||||
File: "readonly",
|
||||
fetch: "readonly",
|
||||
FormData: "readonly",
|
||||
Headers: "readonly",
|
||||
HTMLTextAreaElement: "readonly",
|
||||
localStorage: "readonly",
|
||||
navigator: "readonly",
|
||||
process: "readonly",
|
||||
React: "readonly",
|
||||
RequestInit: "readonly",
|
||||
Response: "readonly",
|
||||
setInterval: "readonly",
|
||||
setTimeout: "readonly",
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
window: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-expressions": "warn",
|
||||
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||
"no-undef": "off",
|
||||
"no-useless-escape": "warn",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 729 KiB |
Generated
+1367
-102
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -7,7 +7,11 @@
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format:check": "prettier --check .github/workflows/web-quality.yml eslint.config.js scripts/run-unit-tests.mjs src/test src/api/generationConcurrency.test.ts src/utils/enterpriseVideoPolicy.test.ts src/utils/taskLifecycle.test.ts",
|
||||
"verify": "npm run test && npm run type-check && npm run lint && npm run format:check && npm run governance:check && npm run style:check && npm run build",
|
||||
"governance:check": "node scripts/check-governance.mjs",
|
||||
"style:check": "node scripts/check-style-governance.mjs",
|
||||
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
||||
@@ -20,13 +24,18 @@
|
||||
"zustand": "5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"playwright": "1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sharp": "0.34.5",
|
||||
"typescript": "5.3.3",
|
||||
"vite": "5.1.0",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "5.4.21",
|
||||
"vite-plugin-compression2": "2.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
const importMap = new Map(); // file -> [imports]
|
||||
const importedBy = new Map(); // file -> [importers]
|
||||
|
||||
for (const r of results) {
|
||||
const imports = [];
|
||||
@@ -71,10 +70,13 @@ function findCircular(file, visited = new Set(), path = []) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of importMap.keys()) {
|
||||
findCircular(file);
|
||||
}
|
||||
|
||||
// Check high-fanin files (imported by many)
|
||||
const fanIn = new Map();
|
||||
for (const [file, imports] of importMap) {
|
||||
for (const imports of importMap.values()) {
|
||||
for (const imp of imports) {
|
||||
const key = imp.replace(/\[dynamic\]/, '');
|
||||
fanIn.set(key, (fanIn.get(key) || 0) + 1);
|
||||
@@ -101,7 +103,7 @@ for (const [file, count] of sortedFanOut) {
|
||||
// Dynamic imports analysis (lazy loading effectiveness)
|
||||
console.log('\n--- Lazy Loading (Dynamic Imports) ---');
|
||||
let dynamicImports = 0, staticImports = 0;
|
||||
for (const [file, imports] of importMap) {
|
||||
for (const imports of importMap.values()) {
|
||||
for (const imp of imports) {
|
||||
if (imp.startsWith('[dynamic]')) dynamicImports++;
|
||||
else staticImports++;
|
||||
@@ -177,6 +179,9 @@ for (const r of results) {
|
||||
if (noDeps > 0) {
|
||||
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`);
|
||||
}
|
||||
if (emptyDeps > 0) {
|
||||
console.log(` [MOUNT-EFFECT] ${r.file}: ${emptyDeps} useEffect(s) run on mount only`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. Zustand Store Analysis ───
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Measures: page load, bundle sizes, memory, rendering, network.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const DIST = join(import.meta.dirname, '..', 'dist');
|
||||
@@ -187,7 +187,7 @@ async function runtimeAnalysis() {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const tagCounts = {};
|
||||
let maxDepth = 0;
|
||||
let totalNodes = allElements.length;
|
||||
const totalNodes = allElements.length;
|
||||
|
||||
allElements.forEach(el => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
@@ -297,7 +297,7 @@ console.log('╔═════════════════════
|
||||
console.log('║ OmniAI Web Preview - Performance Analysis ║');
|
||||
console.log('╚═══════════════════════════════════════════════╝');
|
||||
|
||||
const bundleResult = analyzeBundles();
|
||||
analyzeBundles();
|
||||
await runtimeAnalysis();
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
const result = [];
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...findTestFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (/\.test\.tsx?$/.test(entry.name)) result.push(fullPath);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
configFile: path.join(repoRoot, "vite.config.ts"),
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const harness = await server.ssrLoadModule("/src/test/testHarness");
|
||||
const testFiles = findTestFiles(path.join(repoRoot, "src"));
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.error("No test files found.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Running ${testFiles.length} test files`);
|
||||
|
||||
for (const file of testFiles) {
|
||||
const modulePath = `/${normalizePath(path.relative(repoRoot, file))}`;
|
||||
await server.ssrLoadModule(modulePath);
|
||||
}
|
||||
|
||||
const result = await harness.runRegisteredTests();
|
||||
console.log(`Unit test result: ${result.passed}/${result.total} passed`);
|
||||
if (result.failed > 0) process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', 'src');
|
||||
@@ -114,7 +114,6 @@ for (const r of results) {
|
||||
console.log('\n=== HIGH COMPLEXITY: Deep nesting ===');
|
||||
for (const r of results) {
|
||||
const lines = r.content.split('\n');
|
||||
let maxIndent = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
+15
-26
@@ -295,7 +295,6 @@ function App() {
|
||||
openDeleteProject: openDeleteProjectModal,
|
||||
closeDeleteProject: closeDeleteProjectModal,
|
||||
setDeleteProjectSubmitting,
|
||||
clearProjectState,
|
||||
} = useProjectStore(useShallow((s) => ({
|
||||
projects: s.projects,
|
||||
projectsLoaded: s.projectsLoaded,
|
||||
@@ -310,7 +309,6 @@ function App() {
|
||||
openDeleteProject: s.openDeleteProject,
|
||||
closeDeleteProject: s.closeDeleteProject,
|
||||
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
|
||||
clearProjectState: s.clearProjectState,
|
||||
})));
|
||||
|
||||
// Task store
|
||||
@@ -349,7 +347,6 @@ function App() {
|
||||
setBackendHealth,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
clearAppState,
|
||||
} = useAppStore(useShallow((s) => ({
|
||||
usage: s.usage,
|
||||
runtimeNotifications: s.runtimeNotifications,
|
||||
@@ -370,11 +367,11 @@ function App() {
|
||||
setBackendHealth: s.setBackendHealth,
|
||||
markNotificationRead: s.markNotificationRead,
|
||||
markAllNotificationsRead: s.markAllNotificationsRead,
|
||||
clearAppState: s.clearAppState,
|
||||
})));
|
||||
|
||||
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
||||
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
|
||||
const [onboardingActive, setOnboardingActive] = useState(false);
|
||||
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
||||
useEffect(() => {
|
||||
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
||||
@@ -473,6 +470,17 @@ function App() {
|
||||
}
|
||||
}, [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 }) => {
|
||||
clearAllUserStorage();
|
||||
clearSessionState();
|
||||
@@ -1092,28 +1100,6 @@ function App() {
|
||||
[handleSetView, setImageWorkbenchTool],
|
||||
);
|
||||
|
||||
const renderAdminOnlyPage = useCallback(
|
||||
(content: React.ReactNode) => {
|
||||
if (isAdminAccount(session)) return content;
|
||||
|
||||
return (
|
||||
<div className="feature-access-gate">
|
||||
<div className="feature-access-gate__content" aria-hidden="true">
|
||||
{content}
|
||||
</div>
|
||||
<div className="feature-access-gate__overlay" role="dialog" aria-modal="true" aria-labelledby="feature-access-title">
|
||||
<section className="feature-access-gate__panel panel-surface">
|
||||
<span className="feature-access-gate__eyebrow">功能内测中</span>
|
||||
<h2 id="feature-access-title">暂未开放</h2>
|
||||
<p>敬请期待,该功能还在开发中。</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1320,6 +1306,8 @@ function App() {
|
||||
key={`workbench-${workbenchResetToken}`}
|
||||
isAuthenticated={Boolean(session)}
|
||||
session={session}
|
||||
onboarding={onboardingActive}
|
||||
onEndOnboarding={handleEndOnboarding}
|
||||
onRequireLogin={handleRequireTaskLogin}
|
||||
onOpenResultInCanvas={handleOpenResultInCanvas}
|
||||
onRefreshUsage={refreshUsage}
|
||||
@@ -1330,6 +1318,7 @@ function App() {
|
||||
return (
|
||||
<HomePage
|
||||
onOpenGenerate={() => handleSetView("workbench")}
|
||||
onStartOnboarding={handleStartOnboarding}
|
||||
onOpenCanvas={() => handleSetView("canvas")}
|
||||
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||
onOpenScriptReview={() => handleSetView("scriptTokens")}
|
||||
|
||||
@@ -3,6 +3,19 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
|
||||
if (!signal) return timeoutSignal;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal.aborted || timeoutSignal.aborted) {
|
||||
abort();
|
||||
return controller.signal;
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
timeoutSignal.addEventListener("abort", abort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
@@ -162,9 +175,7 @@ async function chat(
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
@@ -210,9 +221,7 @@ async function visionChat(
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
||||
try {
|
||||
const out = await retryOnTransient(async () => {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
__resetGenerationConcurrencyForTests,
|
||||
claimGenerationSlot,
|
||||
getActiveGenerationTaskCount,
|
||||
getEffectiveGenerationLimit,
|
||||
getGenerationUserKey,
|
||||
releaseGenerationSlot,
|
||||
setUserMaxConcurrency,
|
||||
} from "./generationConcurrency";
|
||||
|
||||
describe("generationConcurrency", () => {
|
||||
afterEach(() => {
|
||||
__resetGenerationConcurrencyForTests();
|
||||
});
|
||||
|
||||
it("uses the default generation limit until the server provides a user-specific limit", () => {
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
|
||||
setUserMaxConcurrency(5);
|
||||
expect(getEffectiveGenerationLimit()).toBe(5);
|
||||
|
||||
setUserMaxConcurrency(0);
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
});
|
||||
|
||||
it("claims and releases local generation slots so the submit button can recover", () => {
|
||||
const userKey = getGenerationUserKey("user-1");
|
||||
|
||||
const releaseFirst = claimGenerationSlot({
|
||||
userKey,
|
||||
kind: "image",
|
||||
id: "slot-1",
|
||||
});
|
||||
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
|
||||
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
|
||||
|
||||
releaseFirst();
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
|
||||
|
||||
releaseGenerationSlot("slot-2");
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
|
||||
});
|
||||
|
||||
it("enforces per-user limits without blocking other users", () => {
|
||||
setUserMaxConcurrency(1);
|
||||
|
||||
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
|
||||
|
||||
expect(() =>
|
||||
claimGenerationSlot({
|
||||
userKey: "alice",
|
||||
kind: "video",
|
||||
id: "alice-slot-2",
|
||||
}),
|
||||
).toThrow("最多生成 1 个图片/视频任务");
|
||||
expect(() =>
|
||||
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -75,3 +75,8 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
|
||||
if (!id) return;
|
||||
activeSlots.delete(id);
|
||||
}
|
||||
|
||||
export function __resetGenerationConcurrencyForTests(): void {
|
||||
activeSlots.clear();
|
||||
userMaxConcurrency = null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import NotificationCenter from "./NotificationCenter";
|
||||
import BetaApplicationModal from "./BetaApplicationModal";
|
||||
import { AnimatedPanel } from "./AnimatedPanel";
|
||||
import AdminMonitor from "./AdminMonitor";
|
||||
import CookieConsentBanner from "./CookieConsentBanner";
|
||||
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
|
||||
import { ShellIcon } from "./ShellIcon";
|
||||
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
|
||||
@@ -76,7 +75,6 @@ function AppShell({
|
||||
session,
|
||||
usage,
|
||||
notifications,
|
||||
backendHealth,
|
||||
workspaceExpanded,
|
||||
onSelectView,
|
||||
onLogout,
|
||||
@@ -85,7 +83,6 @@ function AppShell({
|
||||
onMarkAllNotificationsRead,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const activePackage = session?.user.activePackages?.[0];
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
const submenuHideTimerRef = useRef<number | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import { useCallback } from "react";
|
||||
import "../styles/pages/not-found.css";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useCallback, useEffect, useRef, useState } 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,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactElement } from "react";
|
||||
import "../../styles/pages/assets.css";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -36,7 +36,7 @@ interface AssetsPageProps {
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
|
||||
{ key: "all", label: "全部", icon: null },
|
||||
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
||||
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
||||
@@ -95,6 +95,17 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(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) => {
|
||||
e.preventDefault();
|
||||
@@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
placeholder="搜索资产..."
|
||||
/>
|
||||
</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 ? "上传中..." : "添加"}
|
||||
</button>
|
||||
|
||||
@@ -12,15 +12,11 @@
|
||||
FileImageOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOpenOutlined,
|
||||
MutedOutlined,
|
||||
PauseCircleOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
SoundOutlined,
|
||||
ThunderboltOutlined,
|
||||
UploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
@@ -37,10 +33,7 @@ import { communityClient } from "../../api/communityClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type {
|
||||
WebCanvasWorkflow,
|
||||
WebCanvasWorkflowNodePackage,
|
||||
} from "../../types";
|
||||
import type { WebCanvasWorkflow } from "../../types";
|
||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||
import {
|
||||
buildCanvasCommunityCaseInput,
|
||||
@@ -64,7 +57,6 @@ import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolic
|
||||
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import type {
|
||||
CanvasAlignGuide,
|
||||
CanvasAssetSaveSource,
|
||||
CanvasCopiedNode,
|
||||
CanvasConnectorDrag,
|
||||
@@ -72,37 +64,25 @@ import type {
|
||||
CanvasFloatingMenuPosition,
|
||||
CanvasImageFocusDrag,
|
||||
CanvasImageFocusSelection,
|
||||
CanvasImageGenerationState,
|
||||
CanvasImageNode,
|
||||
CanvasImageNodeDrag,
|
||||
CanvasImageReferenceItem,
|
||||
CanvasManualLink,
|
||||
CanvasNodeBounds,
|
||||
CanvasNodeKind,
|
||||
CanvasNodePackage,
|
||||
CanvasNodePackageDrag,
|
||||
CanvasNodePort,
|
||||
CanvasNodeResizeDrag,
|
||||
CanvasNodeSize,
|
||||
CanvasOption,
|
||||
CanvasPageProps,
|
||||
CanvasPanDrag,
|
||||
CanvasPoint,
|
||||
CanvasProjectSaveState,
|
||||
CanvasSelectedNode,
|
||||
CanvasSelectionDrag,
|
||||
CanvasStyleCase,
|
||||
CanvasStylePickerTab,
|
||||
CanvasStyleReference,
|
||||
CanvasTextGenerationState,
|
||||
CanvasPromptMentionOption,
|
||||
CanvasPromptMentionState,
|
||||
CanvasTextNode,
|
||||
CanvasTextNodeDrag,
|
||||
CanvasVideoGenerationState,
|
||||
CanvasVideoMode,
|
||||
CanvasVideoNode,
|
||||
CanvasVideoNodeDrag,
|
||||
CanvasViewport,
|
||||
} from "./canvasTypes";
|
||||
import {
|
||||
@@ -110,7 +90,6 @@ import {
|
||||
canvasAutoSaveDebounceMs,
|
||||
canvasAutoSaveIdleTimeoutMs,
|
||||
canvasNodeClickMoveThreshold,
|
||||
canvasNodeDefaultSizes,
|
||||
canvasStylePickerCategories,
|
||||
canvasStylePickerTabs,
|
||||
connectorAnchorOutset,
|
||||
@@ -120,22 +99,17 @@ import {
|
||||
defaultImageModel,
|
||||
defaultTextModelId,
|
||||
defaultVideoModel,
|
||||
image4kCapableModels,
|
||||
imageFocusRatioOptions,
|
||||
imageModelOptions as fallbackCanvasImageModelOptions,
|
||||
imageRatioOptions,
|
||||
textModelOptions,
|
||||
videoDurationOptions,
|
||||
videoRatioOptions,
|
||||
} from "./canvasConstants";
|
||||
import {
|
||||
applyImageFocusRatioFromTopLeft,
|
||||
blobToDataUrl,
|
||||
buildCanvasStyleKeywords,
|
||||
buildCopyTitle,
|
||||
clampCanvasPercent,
|
||||
buildReversePromptFromAsset,
|
||||
canvasGenerationProgressStyle,
|
||||
clampCanvasNodeSize,
|
||||
clampCanvasViewportZoom,
|
||||
communityCaseToCanvasStyleCase,
|
||||
@@ -150,15 +124,8 @@ import {
|
||||
getDefaultImageQuality,
|
||||
getDefaultVideoQuality,
|
||||
getImageQualityOptions,
|
||||
getOptionLabel,
|
||||
getVideoQualityOptions,
|
||||
getWorkflowImageNodeFileName,
|
||||
getWorkflowImageNodePrompt,
|
||||
getWorkflowNodeFocusSelection,
|
||||
getWorkflowNodeMetadataString,
|
||||
getWorkflowNodeStyleReference,
|
||||
hasCanvasOptionValue,
|
||||
moveCanvasNodesForPackageDrag,
|
||||
normalizeCanvasGenerationProgress,
|
||||
normalizeCanvasLinkPorts,
|
||||
normalizeCanvasSelectionRect,
|
||||
@@ -166,10 +133,6 @@ import {
|
||||
positionFloatingMenu,
|
||||
resolveImageQuality,
|
||||
resolveVideoQuality,
|
||||
resolveWorkflowImageModel,
|
||||
resolveWorkflowRatio,
|
||||
resolveWorkflowVideoMode,
|
||||
resolveWorkflowVideoModel,
|
||||
waitForImageTaskResult,
|
||||
waitForVideoTaskResult,
|
||||
} from "./canvasUtils";
|
||||
@@ -181,11 +144,9 @@ import {
|
||||
createVideoNodesFromWorkflow,
|
||||
createWorkflowPackagesFromCanvasPackages,
|
||||
formatCanvasProjectUpdatedAt,
|
||||
formatCanvasVideoTime,
|
||||
resolveAssetCategory,
|
||||
} from "./canvasWorkflowDeserialize";
|
||||
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
||||
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
||||
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
|
||||
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
|
||||
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
||||
@@ -396,7 +357,6 @@ function CanvasPage({
|
||||
const canvasUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageNodeInputRef = useRef<HTMLInputElement>(null);
|
||||
const canvasRef = useRef<HTMLElement>(null);
|
||||
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
|
||||
const canvasDragCounterRef = useRef(0);
|
||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||
const suppressNextPaneClickRef = useRef(false);
|
||||
@@ -412,7 +372,7 @@ function CanvasPage({
|
||||
const imageNodeIdRef = useRef(1);
|
||||
const videoNodeIdRef = useRef(1);
|
||||
|
||||
const { pushSnapshot, undo, redo, canUndo, canRedo } = useCanvasHistory();
|
||||
const { pushSnapshot, undo, redo } = useCanvasHistory();
|
||||
const {
|
||||
textGenerationState, imageGenerationState, videoGenerationState,
|
||||
generationToast, setGenerationToast,
|
||||
@@ -1892,7 +1852,7 @@ function CanvasPage({
|
||||
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
||||
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
||||
}
|
||||
let requestModel = resolveVideoRequestModel({ model, referenceUrls });
|
||||
const requestModel = resolveVideoRequestModel({ model, referenceUrls });
|
||||
task = await onCreateTask({
|
||||
title: videoNode.title || "视频节点生成",
|
||||
type: "video",
|
||||
|
||||
@@ -106,7 +106,11 @@ function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas result"));
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Unable to read canvas result"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas result"));
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
ExpandOutlined,
|
||||
MutedOutlined,
|
||||
PauseCircleOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SoundOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
|
||||
import type { CanvasOption } from "./canvasTypes";
|
||||
|
||||
@@ -146,26 +146,6 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const getMaskDataUrl = (): string => {
|
||||
const canvas = canvasRef.current!;
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = canvas.width;
|
||||
maskCanvas.height = canvas.height;
|
||||
const srcCtx = canvas.getContext("2d")!;
|
||||
const maskCtx = maskCanvas.getContext("2d")!;
|
||||
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
|
||||
for (let i = 0; i < imgData.data.length; i += 4) {
|
||||
const hasColor = imgData.data[i + 3] > 10;
|
||||
maskData.data[i] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 1] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 2] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
return maskCanvas.toDataURL("image/png");
|
||||
};
|
||||
|
||||
const handleInpaint = useCallback(async () => {
|
||||
if (!imageUrl || !prompt) {
|
||||
toast.error("请输入重绘提示词");
|
||||
@@ -174,7 +154,6 @@ export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProp
|
||||
setLoading(true);
|
||||
cancelRef.current = false;
|
||||
try {
|
||||
const maskDataUrl = getMaskDataUrl();
|
||||
const { taskId } = await aiGenerationClient.createImageEditTask({
|
||||
imageUrl,
|
||||
function: "inpaint",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { aiGenerationClient, type AiTaskStatus } from "../../api/aiGenerationClient";
|
||||
import type { AiTaskStatus } from "../../api/aiGenerationClient";
|
||||
import type { ServerCommunityCase } from "../../api/communityClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import type { WebCanvasWorkflow } from "../../types";
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
CanvasVideoMode,
|
||||
} from "./canvasTypes";
|
||||
import {
|
||||
assetLibraryCategories,
|
||||
assetTypePromptLabel,
|
||||
canvasNodeDefaultSizes,
|
||||
canvasNodeMaxSizes,
|
||||
@@ -194,7 +193,7 @@ export function resolveWorkflowImageModel(node: WebCanvasWorkflow["nodes"][numbe
|
||||
return defaultImageModel;
|
||||
}
|
||||
|
||||
export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) {
|
||||
export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], _workflowModel: string) {
|
||||
const raw = getWorkflowNodeMetadataString(node, "model");
|
||||
const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw)));
|
||||
if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel;
|
||||
@@ -242,7 +241,11 @@ export function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas image"));
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Unable to read canvas image"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas image"));
|
||||
reader.readAsDataURL(blob);
|
||||
@@ -253,7 +256,9 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "image",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
@@ -264,7 +269,9 @@ export async function waitForVideoTaskResult(taskId: string, onStatus?: (status:
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "video",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
|
||||
@@ -90,7 +90,7 @@ export function buildCanvasVideoTaskInput(workflow: WebCanvasWorkflow, nodeId: s
|
||||
const params = context.node.params || {};
|
||||
const referenceUrls = context.imageReferences.map((item) => item.url);
|
||||
const displayModel = toHappyHorseDisplayModel(String(params.model || workflow.settings.model || "happyhorse-1.0"));
|
||||
let model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
|
||||
const model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
|
||||
return {
|
||||
title: context.node.label || "视频节点生成",
|
||||
type: "video",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Dispatch, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
CanvasImageGenerationState,
|
||||
CanvasImageNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useState } from "react";
|
||||
import { canvasNodeClickMoveThreshold } from "./canvasConstants";
|
||||
import type {
|
||||
CanvasAlignGuide,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
RightOutlined,
|
||||
ScissorOutlined,
|
||||
SwapOutlined,
|
||||
ThunderboltOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
@@ -61,6 +60,9 @@ function CharacterMixPage({
|
||||
const abortRef = useRef(false);
|
||||
const taskIdRef = useRef<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||
const characterInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -262,6 +264,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 (
|
||||
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
|
||||
<header className="image-workbench-topbar">
|
||||
@@ -342,6 +361,7 @@ function CharacterMixPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={characterInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
@@ -383,6 +403,7 @@ function CharacterMixPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(event) => {
|
||||
@@ -441,12 +462,21 @@ function CharacterMixPage({
|
||||
) : null}
|
||||
</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">
|
||||
<SwapOutlined />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PictureOutlined,
|
||||
UploadOutlined,
|
||||
} 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 { communityClient } from "../../api/communityClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
@@ -73,6 +73,29 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
const allowed = canManageCommunityCases(session);
|
||||
const imageInputRef = 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 [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -331,7 +354,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
</label>
|
||||
<div className="community-case-add-upload-row">
|
||||
<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 />
|
||||
上传图片
|
||||
</button>
|
||||
@@ -345,7 +375,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
<>
|
||||
<div className="community-case-add-upload-row">
|
||||
<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 />
|
||||
上传 JSON
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RightOutlined,
|
||||
ScissorOutlined,
|
||||
SwapOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
@@ -98,6 +97,9 @@ function DigitalHumanPage({
|
||||
activeTaskIdRef.current = activeTaskId;
|
||||
const keepaliveRestoredRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -171,6 +173,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 () => {
|
||||
if (!resultVideoUrl || isDownloadingResult) return;
|
||||
setIsDownloadingResult(true);
|
||||
@@ -463,6 +498,7 @@ function DigitalHumanPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
@@ -501,6 +537,7 @@ function DigitalHumanPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(event) => {
|
||||
@@ -541,12 +578,21 @@ function DigitalHumanPage({
|
||||
<img src={imagePreview} alt="参考人像" />
|
||||
</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">
|
||||
<CustomerServiceOutlined />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
CloudUploadOutlined,
|
||||
CloseOutlined,
|
||||
FileImageOutlined,
|
||||
FrownOutlined,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
MenuUnfoldOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
SkinOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
@@ -624,11 +622,6 @@ const tryOnModelOptions = {
|
||||
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
|
||||
body: ["标准", "高挑", "微胖", "运动"],
|
||||
};
|
||||
const sampleResults = [
|
||||
ossAssets.ecommerce.slides.slide4,
|
||||
ossAssets.ecommerce.generated,
|
||||
ossAssets.ecommerce.slides.slide5,
|
||||
];
|
||||
const productSetAssets = ossAssets.ecommerce.productSet;
|
||||
const productSetPreviewCards = [
|
||||
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
|
||||
@@ -701,21 +694,13 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
|
||||
});
|
||||
}
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
|
||||
const selectedFiles = Array.from(files).slice(0, limit);
|
||||
const stamp = Date.now();
|
||||
|
||||
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
let dimensions: { width?: number; height?: number } = {};
|
||||
let dimensions: { width?: number; height?: number };
|
||||
try {
|
||||
dimensions = await readImageDimensions(localPreviewUrl);
|
||||
} catch {
|
||||
@@ -988,6 +973,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const selectedProductSetOutput =
|
||||
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
||||
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
||||
const cloneRequirementPlaceholder =
|
||||
cloneOutput === "model"
|
||||
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描写(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
|
||||
: "建议包含以下信息,产品名称,核心卖点,期望场景,具体参数";
|
||||
const productSetPreviewReady = productSetStatus === "done";
|
||||
const cloneSetTotal = useMemo(
|
||||
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
||||
@@ -1698,7 +1687,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
}
|
||||
|
||||
const generatedUrls: string[] = [];
|
||||
const stamp = Date.now();
|
||||
|
||||
for (const countKey of cloneSetCountKeys) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
@@ -1934,7 +1922,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
age: cloneModelAge,
|
||||
ethnicity: cloneModelEthnicity,
|
||||
body: cloneModelBody,
|
||||
appearance: cloneModelAppearance,
|
||||
scenes: selectedCloneModelScenes,
|
||||
customScene: cloneModelCustomScene,
|
||||
}
|
||||
@@ -2036,7 +2023,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
};
|
||||
|
||||
const resetTask = () => {
|
||||
const _resetTask = () => {
|
||||
setSetImages([]);
|
||||
setProductSetRequirement("");
|
||||
setProductSetOutput("video");
|
||||
@@ -2225,7 +2212,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
cloneModelSelects={cloneModelSelects}
|
||||
openCloneModelSelect={openCloneModelSelect}
|
||||
cloneModelSelectDropUp={cloneModelSelectDropUp}
|
||||
cloneModelAppearance={cloneModelAppearance}
|
||||
cloneVideoQuality={cloneVideoQuality}
|
||||
cloneVideoQualityOptions={cloneVideoQualityOptions}
|
||||
cloneVideoDuration={cloneVideoDuration}
|
||||
@@ -2257,7 +2243,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setCloneModelCustomScene={setCloneModelCustomScene}
|
||||
setOpenCloneModelSelect={setOpenCloneModelSelect}
|
||||
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
|
||||
setCloneModelAppearance={setCloneModelAppearance}
|
||||
setCloneVideoQuality={setCloneVideoQuality}
|
||||
setCloneVideoDuration={setCloneVideoDuration}
|
||||
clampCloneVideoDuration={clampCloneVideoDuration}
|
||||
@@ -2620,7 +2605,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (event.key === "Escape") setRequirementImageMentionQuery(null);
|
||||
}}
|
||||
maxLength={500}
|
||||
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
|
||||
placeholder={cloneRequirementPlaceholder}
|
||||
/>
|
||||
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
|
||||
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "../../styles/pages/ecommerce-video.css";
|
||||
import {
|
||||
CloseOutlined,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||||
import {
|
||||
PLAN_STEP_LABELS,
|
||||
PLAN_STEPS_DISPLAY,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanProgress,
|
||||
@@ -29,7 +28,6 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
import {
|
||||
saveEcommerceVideoState,
|
||||
loadEcommerceVideoState,
|
||||
clearEcommerceVideoState,
|
||||
} from "./ecommerceVideoKeepalive";
|
||||
|
||||
interface EcommerceVideoWorkspaceProps {
|
||||
@@ -298,7 +296,7 @@ export default function EcommerceVideoWorkspace({
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
const _handleDownload = async (url: string) => {
|
||||
try {
|
||||
await saveToolResultToLocal({
|
||||
url, name: `ecommerce-video-${Date.now()}`, type: "video",
|
||||
@@ -310,7 +308,7 @@ export default function EcommerceVideoWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsset = async (url: string) => {
|
||||
const _handleSaveAsset = async (url: string) => {
|
||||
try {
|
||||
const result = await addToolResultToAssetLibrary({
|
||||
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
|
||||
@@ -596,9 +594,6 @@ export default function EcommerceVideoWorkspace({
|
||||
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
|
||||
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
|
||||
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
||||
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||||
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||||
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
|
||||
|
||||
return (
|
||||
<div className="ecom-video-workspace" data-stage={stage}>
|
||||
|
||||
@@ -19,7 +19,7 @@ const [
|
||||
ecommerceCarouselImage2,
|
||||
ecommerceCarouselImage3,
|
||||
ecommerceCarouselImage4,
|
||||
ecommerceCarouselImage5,
|
||||
,
|
||||
ecommerceCarouselImage6,
|
||||
] = ossAssets.ecommerce.templateCases;
|
||||
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
|
||||
|
||||
@@ -100,7 +100,6 @@ interface EcommerceClonePanelProps {
|
||||
cloneModelSelects: CloneModelSelectItem[];
|
||||
openCloneModelSelect: CloneModelSelectKey | null;
|
||||
cloneModelSelectDropUp: boolean;
|
||||
cloneModelAppearance: string;
|
||||
cloneVideoQuality: CloneVideoQualityKey;
|
||||
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||
cloneVideoDuration: number;
|
||||
@@ -132,7 +131,6 @@ interface EcommerceClonePanelProps {
|
||||
setCloneModelCustomScene: (value: string) => void;
|
||||
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
||||
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||
setCloneModelAppearance: (value: string) => void;
|
||||
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||
setCloneVideoDuration: (value: number) => void;
|
||||
clampCloneVideoDuration: (value: number) => number;
|
||||
@@ -172,7 +170,6 @@ export default function EcommerceClonePanel({
|
||||
cloneModelSelects,
|
||||
openCloneModelSelect,
|
||||
cloneModelSelectDropUp,
|
||||
cloneModelAppearance,
|
||||
cloneVideoQuality,
|
||||
cloneVideoQualityOptions,
|
||||
cloneVideoDuration,
|
||||
@@ -204,7 +201,6 @@ export default function EcommerceClonePanel({
|
||||
setCloneModelCustomScene,
|
||||
setOpenCloneModelSelect,
|
||||
setCloneModelSelectDropUp,
|
||||
setCloneModelAppearance,
|
||||
setCloneVideoQuality,
|
||||
setCloneVideoDuration,
|
||||
clampCloneVideoDuration,
|
||||
@@ -668,14 +664,6 @@ export default function EcommerceClonePanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="clone-ai-model-textarea">
|
||||
<strong>外貌细节(可选)</strong>
|
||||
<textarea
|
||||
value={cloneModelAppearance}
|
||||
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
||||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -758,7 +746,7 @@ export default function EcommerceClonePanel({
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
||||
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
|
||||
{videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
|
||||
</button>
|
||||
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
||||
</div>
|
||||
@@ -774,7 +762,7 @@ export default function EcommerceClonePanel({
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
||||
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
|
||||
{videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
|
||||
</button>
|
||||
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
interface EcommerceDetailPanelProps {
|
||||
@@ -59,6 +59,31 @@ export default function EcommerceDetailPanel({
|
||||
handleDetailGenerate,
|
||||
onCancelGenerate,
|
||||
}: 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 (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
@@ -67,7 +92,14 @@ export default function EcommerceDetailPanel({
|
||||
商品原图
|
||||
<QuestionCircleOutlined />
|
||||
</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>
|
||||
<CloudUploadOutlined />
|
||||
上传图片
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
interface EcommerceTryOnPanelProps {
|
||||
@@ -73,12 +73,44 @@ export default function EcommerceTryOnPanel({
|
||||
handleTryOnGenerate,
|
||||
onCancelGenerate,
|
||||
}: 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 (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
<section className="product-clone-field">
|
||||
<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>
|
||||
<CloudUploadOutlined />
|
||||
服装图片
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import "../../styles/pages/home.css";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
@@ -17,15 +15,6 @@ import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
import ModelGenerationShowcase from "./ModelGenerationShowcase";
|
||||
|
||||
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
|
||||
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
|
||||
return (
|
||||
<section ref={ref} className={`${className ?? ""} scroll-entrance${isVisible ? " is-visible" : ""}`} {...rest}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
|
||||
const {
|
||||
ecommerce: featureEcommerceImage,
|
||||
@@ -35,6 +24,7 @@ const {
|
||||
|
||||
interface HomePageProps {
|
||||
onOpenGenerate: () => void;
|
||||
onStartOnboarding?: () => void;
|
||||
onOpenCanvas?: () => void;
|
||||
onOpenEcommerce: () => void;
|
||||
onOpenScriptReview?: () => void;
|
||||
@@ -194,10 +184,18 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 190, 320, 430, 520, 590];
|
||||
const xByDepth = [
|
||||
"0px",
|
||||
"clamp(52px, 13.5vw, 198px)",
|
||||
"clamp(90px, 22.5vw, 334px)",
|
||||
"clamp(122px, 30.5vw, 448px)",
|
||||
"clamp(148px, 37vw, 542px)",
|
||||
"clamp(170px, 42vw, 614px)",
|
||||
];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const scaleByDepth = [1, 1, 1, 1, 1, 1];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const xDistance = xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!;
|
||||
const x = direction < 0 ? `calc(0px - ${xDistance})` : xDistance;
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
|
||||
@@ -206,7 +204,7 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
|
||||
"--apple-card-offset": offset,
|
||||
"--apple-card-depth": depth,
|
||||
"--apple-card-z": 80 - depth,
|
||||
"--apple-card-x": `${x}px`,
|
||||
"--apple-card-x": x,
|
||||
"--apple-card-y": `${y}px`,
|
||||
"--apple-card-z-offset": `${z}px`,
|
||||
"--apple-card-rotate-y": "0deg",
|
||||
@@ -468,7 +466,7 @@ function EcommerceFeatureShowcase() {
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
|
||||
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onSelectView, onOpenImageTool }: HomePageProps) {
|
||||
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
|
||||
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
|
||||
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
|
||||
@@ -620,17 +618,26 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
<span>新手</span>
|
||||
<span>
|
||||
快速生成
|
||||
<small>新手友好</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenCanvas || onOpenGenerate}>
|
||||
<PlayCircleOutlined />
|
||||
<span>老手</span>
|
||||
<span>
|
||||
专业创作
|
||||
<small>画布工作流</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="omni-home__entry" onClick={onOpenEcommerce}>
|
||||
<ShoppingOutlined />
|
||||
<span>电商</span>
|
||||
<span>
|
||||
电商出图
|
||||
<small>商品视觉</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -14,7 +14,6 @@ function ScriptReviewVisual() {
|
||||
const [animated, setAnimated] = useState(false);
|
||||
const [activeDim, setActiveDim] = useState<number | null>(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const scoreRef = useRef<number>(0);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -145,7 +145,7 @@ const CARDS = [
|
||||
},
|
||||
];
|
||||
|
||||
function ToolboxSection({ onSelectView, onOpenImageTool }: ToolboxSectionProps) {
|
||||
function ToolboxSection({ onSelectView }: ToolboxSectionProps) {
|
||||
const handleCardClick = (targetView: WebViewKey) => {
|
||||
onSelectView(targetView);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ScissorOutlined,
|
||||
SwapOutlined,
|
||||
TableOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
@@ -30,7 +29,6 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
|
||||
import { useCanvasDrawing } from "./useCanvasDrawing";
|
||||
import CameraViewport3D from "./CameraViewport3D";
|
||||
@@ -40,7 +38,6 @@ type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
|
||||
type OutputCount = 1 | 2 | 3 | 4;
|
||||
|
||||
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
|
||||
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
|
||||
|
||||
const SIZE_TO_RATIO: Record<OutputSize, string> = {
|
||||
"9:16": "9:16",
|
||||
@@ -947,19 +944,22 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
|
||||
<div
|
||||
className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => inpaintFileInputRef.current?.click()}
|
||||
onDragOver={handleInpaintDragOver}
|
||||
onDragLeave={handleInpaintDragLeave}
|
||||
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 />
|
||||
<strong>拖拽或选择图片</strong>
|
||||
<span>支持 PNG / JPG / WebP</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传后使用画笔标注需要重绘的区域</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1389,12 +1389,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
<img src={referenceImage} alt="参考图预览" />
|
||||
</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">
|
||||
<PictureOutlined />
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
+173
-131
@@ -37,117 +37,153 @@ interface MoreTool {
|
||||
imageTool?: WebImageWorkbenchTool;
|
||||
ready: boolean;
|
||||
badge?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
type CompareScene =
|
||||
| "workbench"
|
||||
| "inpaint"
|
||||
| "camera"
|
||||
| "upscale"
|
||||
| "watermark"
|
||||
| "dialog"
|
||||
| "subtitle"
|
||||
| "digital-human"
|
||||
| "character"
|
||||
| "avatar";
|
||||
|
||||
const toolCompareScenes: Record<string, CompareScene> = {
|
||||
workbench: "workbench",
|
||||
inpaint: "inpaint",
|
||||
camera: "camera",
|
||||
upscale: "upscale",
|
||||
watermarkRemoval: "watermark",
|
||||
dialogGenerator: "dialog",
|
||||
subtitleRemoval: "subtitle",
|
||||
digitalHuman: "digital-human",
|
||||
characterMix: "character",
|
||||
avatarConsole: "avatar",
|
||||
const toolPreviewImages: Record<string, string> = {
|
||||
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
|
||||
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
|
||||
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
|
||||
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
|
||||
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
|
||||
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
|
||||
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
|
||||
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
|
||||
};
|
||||
|
||||
function ToolComparePanel({ scene }: { scene: CompareScene }) {
|
||||
function ToolPreviewPanel({ toolId }: { toolId: string }) {
|
||||
const imageUrl = toolPreviewImages[toolId];
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<span className={`more-card__compare more-card__compare--${scene}`} aria-hidden="true">
|
||||
<span className="more-card__compare-labels">
|
||||
<span>Before</span>
|
||||
<span>After</span>
|
||||
</span>
|
||||
<span className="more-card__compare-stage">
|
||||
<span className="more-card__compare-side more-card__compare-side--before">
|
||||
<span className="more-card__scene-subject" />
|
||||
<span className="more-card__scene-detail" />
|
||||
<span className="more-card__scene-overlay" />
|
||||
</span>
|
||||
<span className="more-card__compare-divider">
|
||||
<span />
|
||||
</span>
|
||||
<span className="more-card__compare-side more-card__compare-side--after">
|
||||
<span className="more-card__scene-subject" />
|
||||
<span className="more-card__scene-detail" />
|
||||
<span className="more-card__scene-overlay" />
|
||||
</span>
|
||||
<span className="more-card__preview" aria-hidden="true">
|
||||
<span className="more-card__preview-frame">
|
||||
<img src={imageUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
<img className="more-card__preview-popover" src={imageUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const tools: MoreTool[] = [
|
||||
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", useCase: "适合商品图精修、创意合成和局部画面重做", tags: ["热门", "一站式", "商品图"], icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
|
||||
{ id: "inpaint", title: "局部重绘", text: "修掉瑕疵、替换物体、重做局部画面", useCase: "适合快速处理商品瑕疵、人物细节和背景杂物", tags: ["新手推荐", "精修"], icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
|
||||
{ id: "camera", title: "镜头实验室", text: "快速生成俯拍、特写、广角等商业镜头", useCase: "适合做产品主图、种草图和不同机位方案", tags: ["电商常用", "镜头"], icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
|
||||
{ id: "upscale", title: "分辨率提升", text: "把低清图片或视频提升到可交付质感", useCase: "适合修复旧素材、放大商品图和增强短视频清晰度", tags: ["高清", "交付前"], icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
|
||||
{ id: "watermarkRemoval", title: "去水印", text: "智能去除图片水印、文字和遮挡元素", useCase: "适合整理素材、清理参考图和恢复画面干净度", tags: ["素材清理", "高频"], icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
|
||||
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,快速制作可拖拽编辑的对话框", useCase: "适合剧情海报、社媒截图和角色对白设计", tags: ["内容创作", "可编辑"], icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
|
||||
{ id: "subtitleRemoval", title: "字幕去除", text: "擦除视频字幕,让画面重新变干净", useCase: "适合二创前素材整理、短视频重剪和画面修复", tags: ["视频增强", "素材清理"], icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
|
||||
{ id: "digitalHuman", title: "数字人", text: "用一张人像和音频生成口播视频", useCase: "适合品牌讲解、课程口播和带货短视频", tags: ["热门", "口播", "视频"], icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
|
||||
{ id: "characterMix", title: "角色迁移", text: "把人物图迁移到参考视频的动作里", useCase: "适合角色短片、动作复刻和虚拟人内容生产", tags: ["角色视频", "动作"], icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
|
||||
{ id: "avatarConsole", title: "数字人控制台", text: "管理形象、播报、互动与接入配置", useCase: "适合持续运营数字人、配置品牌形象和复用口播模板", tags: ["运营台", "企业"], icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
|
||||
];
|
||||
|
||||
interface FeaturedTool {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
kicker: string;
|
||||
steps: string[];
|
||||
outcome: string;
|
||||
icon: ReactNode;
|
||||
imageTool?: WebImageWorkbenchTool;
|
||||
target?: WebViewKey;
|
||||
category: ToolCategory;
|
||||
gradient: string;
|
||||
function getPreviewClassName(toolId: string) {
|
||||
return toolPreviewImages[toolId] ? " more-card--has-preview" : " more-card--no-preview";
|
||||
}
|
||||
|
||||
const featuredTools: FeaturedTool[] = [
|
||||
const tools: MoreTool[] = [
|
||||
{
|
||||
id: "workbench",
|
||||
title: "图片工作台",
|
||||
desc: "从一张素材开始,完成精修、合成和二次创作。",
|
||||
kicker: "图片精修工作流",
|
||||
steps: ["上传素材", "局部修复", "高清导出"],
|
||||
outcome: "适合商品图、海报图和创意视觉",
|
||||
text: "融合、修复、局部增强",
|
||||
useCase: "适合商品图精修、创意合成和局部画面重做",
|
||||
tags: ["热门", "一站式", "商品图"],
|
||||
icon: <EditOutlined />,
|
||||
imageTool: "workbench",
|
||||
category: "image",
|
||||
gradient: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
|
||||
imageTool: "workbench",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "inpaint",
|
||||
title: "局部重绘",
|
||||
text: "修掉瑕疵、替换物体、重做局部画面",
|
||||
useCase: "适合快速处理商品瑕疵、人物细节和背景杂物",
|
||||
tags: ["新手推荐", "精修"],
|
||||
icon: <HighlightOutlined />,
|
||||
category: "image",
|
||||
imageTool: "inpaint",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
title: "镜头实验室",
|
||||
text: "快速生成俯拍、特写、广角等商业镜头",
|
||||
useCase: "适合做产品主图、种草图和不同机位方案",
|
||||
tags: ["电商常用", "镜头"],
|
||||
icon: <CameraOutlined />,
|
||||
category: "image",
|
||||
imageTool: "camera",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
title: "分辨率提升",
|
||||
text: "把低清图片或视频提升到可交付质感",
|
||||
useCase: "适合修复旧素材、放大商品图和增强短视频清晰度",
|
||||
tags: ["高清", "交付前"],
|
||||
icon: <ColumnWidthOutlined />,
|
||||
category: "image",
|
||||
target: "resolutionUpscale",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "watermarkRemoval",
|
||||
title: "去水印",
|
||||
text: "智能去除图片水印、文字和遮挡元素",
|
||||
useCase: "适合整理素材、清理参考图和恢复画面干净度",
|
||||
tags: ["素材清理", "高频"],
|
||||
icon: <DeleteOutlined />,
|
||||
category: "image",
|
||||
target: "watermarkRemoval",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "dialogGenerator",
|
||||
title: "交互式对话框生成器",
|
||||
text: "上传背景图,快速制作可拖拽编辑的对话框",
|
||||
useCase: "适合剧情海报、社媒截图和角色对白设计",
|
||||
tags: ["内容创作", "可编辑"],
|
||||
icon: <MessageOutlined />,
|
||||
category: "image",
|
||||
target: "dialogGenerator",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "subtitleRemoval",
|
||||
title: "字幕去除",
|
||||
text: "擦除视频字幕,让画面重新变干净",
|
||||
useCase: "适合二创前素材整理、短视频重剪和画面修复",
|
||||
tags: ["视频增强", "素材清理"],
|
||||
icon: <DeleteOutlined />,
|
||||
category: "video",
|
||||
target: "subtitleRemoval",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "digitalHuman",
|
||||
title: "数字人",
|
||||
desc: "用参考人像和音频,快速生成可交付口播视频。",
|
||||
kicker: "口播视频工作流",
|
||||
steps: ["选择人像", "上传音频", "生成视频"],
|
||||
outcome: "适合品牌讲解、课程和带货短视频",
|
||||
text: "用一张人像和音频生成口播视频",
|
||||
useCase: "适合品牌讲解、课程口播和带货短视频",
|
||||
tags: ["热门", "口播", "视频"],
|
||||
icon: <CustomerServiceOutlined />,
|
||||
target: "digitalHuman",
|
||||
category: "video",
|
||||
gradient: "linear-gradient(135deg, rgba(13, 148, 136, 0.12), rgba(6, 182, 212, 0.06))",
|
||||
target: "digitalHuman",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "characterMix",
|
||||
title: "角色迁移",
|
||||
text: "把人物图迁移到参考视频的动作里",
|
||||
useCase: "适合角色短片、动作复刻和虚拟人内容生产",
|
||||
tags: ["角色视频", "动作"],
|
||||
icon: <SwapOutlined />,
|
||||
category: "video",
|
||||
target: "characterMix",
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "avatarConsole",
|
||||
title: "数字人控制台",
|
||||
text: "管理形象、播报、互动与接入配置",
|
||||
useCase: "适合持续运营数字人、配置品牌形象和复用口播模板",
|
||||
tags: ["运营台", "企业"],
|
||||
icon: <DashboardOutlined />,
|
||||
category: "video",
|
||||
target: "avatarConsole",
|
||||
ready: true,
|
||||
},
|
||||
];
|
||||
|
||||
const categoryLabels: Record<ToolCategory, string> = {
|
||||
image: "图像创作",
|
||||
video: "视频生成",
|
||||
video: "视频创作",
|
||||
};
|
||||
|
||||
const categoryIcons: Record<ToolCategory, ReactNode> = {
|
||||
@@ -162,6 +198,20 @@ const filters: { key: FilterKey; label: string }[] = [
|
||||
{ key: "upcoming", label: "即将上线" },
|
||||
];
|
||||
|
||||
const coreToolIds = new Set(["workbench", "inpaint", "watermarkRemoval"]);
|
||||
|
||||
const coreToolGradients: Record<string, string> = {
|
||||
workbench: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
|
||||
inpaint: "linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.055))",
|
||||
watermarkRemoval: "linear-gradient(135deg, rgba(16, 185, 129, 0.13), rgba(var(--accent-rgb), 0.055))",
|
||||
};
|
||||
|
||||
const coreToolSteps: Record<string, string[]> = {
|
||||
workbench: ["上传素材", "局部修复", "高清导出"],
|
||||
inpaint: ["选定区域", "描述修改", "生成结果"],
|
||||
watermarkRemoval: ["上传素材", "智能识别", "干净导出"],
|
||||
};
|
||||
|
||||
const RECENT_STORAGE_KEY = "omniai:more-recent-tools";
|
||||
const MAX_RECENT = 4;
|
||||
|
||||
@@ -169,7 +219,9 @@ function getRecentToolIds(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecentToolId(id: string) {
|
||||
@@ -199,39 +251,29 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
}
|
||||
}, [onOpenImageTool, onSelectView]);
|
||||
|
||||
const openFeaturedTool = useCallback((tool: FeaturedTool) => {
|
||||
pushRecentToolId(tool.id);
|
||||
setRecentIds(getRecentToolIds());
|
||||
if (tool.imageTool && onOpenImageTool) {
|
||||
onOpenImageTool(tool.imageTool);
|
||||
return;
|
||||
}
|
||||
if (tool.target && onSelectView) {
|
||||
onSelectView(tool.target);
|
||||
}
|
||||
}, [onOpenImageTool, onSelectView]);
|
||||
|
||||
const filteredTools = tools.filter((t) => {
|
||||
if (t.featured) return false;
|
||||
const filteredTools = tools.filter((tool) => {
|
||||
if (coreToolIds.has(tool.id)) return false;
|
||||
if (filter === "all") return true;
|
||||
if (filter === "upcoming") return !t.ready;
|
||||
return t.category === filter;
|
||||
if (filter === "upcoming") return !tool.ready;
|
||||
return tool.category === filter;
|
||||
});
|
||||
|
||||
const filterCounts: Record<FilterKey, number> = {
|
||||
all: tools.filter((t) => !t.featured).length,
|
||||
image: tools.filter((t) => !t.featured && t.category === "image").length,
|
||||
video: tools.filter((t) => !t.featured && t.category === "video").length,
|
||||
upcoming: tools.filter((t) => !t.featured && !t.ready).length,
|
||||
all: tools.filter((tool) => !coreToolIds.has(tool.id)).length,
|
||||
image: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "image").length,
|
||||
video: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "video").length,
|
||||
upcoming: tools.filter((tool) => !coreToolIds.has(tool.id) && !tool.ready).length,
|
||||
};
|
||||
|
||||
const recentTools = recentIds
|
||||
.map((id) => tools.find((t) => t.id === id))
|
||||
.filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
|
||||
.map((id) => tools.find((tool) => tool.id === id))
|
||||
.filter((tool): tool is MoreTool => Boolean(tool) && (tool?.ready ?? false));
|
||||
|
||||
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
|
||||
if (!acc[t.category]) acc[t.category] = [];
|
||||
acc[t.category].push(t);
|
||||
const coreTools = tools.filter((tool) => coreToolIds.has(tool.id));
|
||||
|
||||
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, tool) => {
|
||||
if (!acc[tool.category]) acc[tool.category] = [];
|
||||
acc[tool.category].push(tool);
|
||||
return acc;
|
||||
}, {} as Record<ToolCategory, MoreTool[]>);
|
||||
|
||||
@@ -247,19 +289,19 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
</div>
|
||||
<div className="more-page-v2__header-meta" aria-label="工具盒概览">
|
||||
<span>{tools.filter((tool) => tool.ready).length} 个可用工具</span>
|
||||
<span>{featuredTools.length} 个核心入口</span>
|
||||
<span>{coreTools.length} 个核心入口</span>
|
||||
</div>
|
||||
<nav className="more-page-v2__filters" aria-label="工具分类筛选">
|
||||
{filters.map((f) => (
|
||||
{filters.map((item) => (
|
||||
<button
|
||||
key={f.key}
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={filter === f.key ? "is-active" : ""}
|
||||
aria-pressed={filter === f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={filter === item.key ? "is-active" : ""}
|
||||
aria-pressed={filter === item.key}
|
||||
onClick={() => setFilter(item.key)}
|
||||
>
|
||||
<span>{f.label}</span>
|
||||
<em>{filterCounts[f.key]}</em>
|
||||
<span>{item.label}</span>
|
||||
<em>{filterCounts[item.key]}</em>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -298,27 +340,28 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
<ThunderboltOutlined /> 核心工具
|
||||
</h2>
|
||||
<div className="more-page-v2__featured-grid">
|
||||
{featuredTools.map((tool) => (
|
||||
{coreTools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className="more-card more-card--featured"
|
||||
style={{ "--card-gradient": tool.gradient } as CSSProperties}
|
||||
aria-label={`打开核心工具:${tool.title},${tool.desc}`}
|
||||
onClick={() => openFeaturedTool(tool)}
|
||||
className={`more-card more-card--featured${getPreviewClassName(tool.id)}`}
|
||||
style={{
|
||||
"--card-gradient": coreToolGradients[tool.id] ?? "linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04))",
|
||||
} as CSSProperties}
|
||||
aria-label={`打开核心工具:${tool.title},${tool.text}`}
|
||||
onClick={() => openTool(tool)}
|
||||
>
|
||||
<span className="more-card__featured-icon">{tool.icon}</span>
|
||||
<div className="more-card__featured-body">
|
||||
<span className="more-card__featured-kicker">{tool.kicker}</span>
|
||||
<span className="more-card__featured-kicker">{categoryLabels[tool.category]}</span>
|
||||
<strong>{tool.title}</strong>
|
||||
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
|
||||
<span className="more-card__featured-desc">{tool.desc}</span>
|
||||
<ToolPreviewPanel toolId={tool.id} />
|
||||
<span className="more-card__featured-desc">{tool.text}</span>
|
||||
<span className="more-card__steps" aria-hidden="true">
|
||||
{tool.steps.map((step) => (
|
||||
{(coreToolSteps[tool.id] ?? tool.tags.slice(0, 3)).map((step) => (
|
||||
<span key={step}>{step}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="more-card__outcome">{tool.outcome}</span>
|
||||
<span className="more-card__outcome">{tool.useCase}</span>
|
||||
<span className="more-card__cta">开始使用 →</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -341,19 +384,18 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}`}
|
||||
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}${getPreviewClassName(tool.id)}`}
|
||||
aria-label={tool.ready ? `打开工具:${tool.title},${tool.text}` : `${tool.title}暂未开放`}
|
||||
onClick={() => openTool(tool)}
|
||||
disabled={!tool.ready}
|
||||
>
|
||||
<span className="more-card__icon">{tool.icon}</span>
|
||||
<span className="more-card__topline">
|
||||
{tool.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
<strong>{tool.title}</strong>
|
||||
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
|
||||
<ToolPreviewPanel toolId={tool.id} />
|
||||
<span className="more-card__desc">{tool.text}</span>
|
||||
<span className="more-card__use-case">{tool.useCase}</span>
|
||||
<span className="more-card__action">打开工具 →</span>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ShareAltOutlined,
|
||||
UserOutlined,
|
||||
} 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 "../../styles/pages/profile.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -228,6 +228,28 @@ function ProfilePage({
|
||||
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
|
||||
const avatarInputRef = 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 [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={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
|
||||
<header
|
||||
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
|
||||
className={`profile-page__banner${bannerUrl ? " has-image" : ""}${isBannerDragging ? " is-dragging" : ""}`}
|
||||
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="更换背景">
|
||||
<CameraOutlined />
|
||||
@@ -1060,13 +1085,21 @@ function ProfilePage({
|
||||
<div className="profile-page__body">
|
||||
<aside className="profile-page__sidebar">
|
||||
<div className="profile-page__sidebar-head">
|
||||
<div className="profile-page__avatar-ring">
|
||||
<div className={`profile-page__avatar-ring${isAvatarDragging ? " is-dragging" : ""}`}>
|
||||
{avatarUrl ? (
|
||||
<img className="profile-page__avatar" src={avatarUrl} alt="" />
|
||||
) : (
|
||||
<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 />
|
||||
</button>
|
||||
<span className="profile-page__avatar-badge">
|
||||
@@ -1455,6 +1488,32 @@ function ProfilePage({
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
{mode === "register" ? (
|
||||
<label className={`auth-page__field${fieldErrors.emailCode ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
<SafetyOutlined /> 邮箱验证码
|
||||
</span>
|
||||
<div className="auth-page__sms-row">
|
||||
<input
|
||||
value={emailCode}
|
||||
onChange={(event) => { setEmailCode(event.target.value); clearFieldError("emailCode"); }}
|
||||
onBlur={() => handleFieldBlur("emailCode", emailCode)}
|
||||
placeholder="输入 6 位验证码"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-page__sms-btn"
|
||||
disabled={emailCooldown > 0 || !email.trim() || isSendingEmail || !betaCode.trim()}
|
||||
onClick={() => void handleSendEmailCode("register")}
|
||||
>
|
||||
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "获取验证码"}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.emailCode ? <span className="auth-page__field-error">{fieldErrors.emailCode}</span> : null}
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -21,9 +21,8 @@ import "../../styles/pages/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
|
||||
import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
|
||||
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
|
||||
import TaskStatusBar from "../../components/TaskStatusBar";
|
||||
import BeforeAfterCompare from "../../components/BeforeAfterCompare";
|
||||
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
|
||||
@@ -601,11 +600,20 @@ function ResolutionUpscalePage({
|
||||
</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">
|
||||
<ThunderboltOutlined />
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
BarChartOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
LoadingOutlined,
|
||||
ThunderboltOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
@@ -264,7 +258,7 @@ function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[]
|
||||
return normalizeEvidenceItems(evidence, 3);
|
||||
}
|
||||
|
||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
function formatReportMarkdown(result: EvalResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# 剧本评测报告`);
|
||||
lines.push("");
|
||||
@@ -438,7 +432,7 @@ function ScriptTokensPage() {
|
||||
|
||||
const handleCopyReport = async () => {
|
||||
if (!result) return;
|
||||
const text = formatReportMarkdown(result, script);
|
||||
const text = formatReportMarkdown(result);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
@@ -459,7 +453,7 @@ function ScriptTokensPage() {
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
if (!result) return;
|
||||
const md = formatReportMarkdown(result, script);
|
||||
const md = formatReportMarkdown(result);
|
||||
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -679,14 +673,23 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
) : !result && (
|
||||
<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
|
||||
className="script-eval-v5-illustration-hit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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">
|
||||
<ShellIcon name="file-text" />
|
||||
</div>
|
||||
|
||||
@@ -130,8 +130,6 @@ function TokenUsagePage({
|
||||
loadEnterpriseUsage,
|
||||
loadPersonalUsage,
|
||||
onOpenMore,
|
||||
onOpenImageTool,
|
||||
onSelectView,
|
||||
}: TokenUsagePageProps) {
|
||||
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
||||
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
|
||||
|
||||
@@ -947,7 +947,6 @@ function SizeTemplatePage({ onOpenEcommerce }: SizeTemplatePageProps) {
|
||||
);
|
||||
const selectedPreset =
|
||||
filteredTemplates.find((item) => item.title === activePresetTitle) ?? filteredTemplates[0] ?? sizeTemplatePresets[0]!;
|
||||
const activeGroupLabel = sizeTemplateGroups.find((item) => item.key === selectedPreset.group)?.label ?? "尺寸模板";
|
||||
const platformOptions =
|
||||
activeGroup === "socialCn"
|
||||
? socialContentPlatformOptions
|
||||
|
||||
@@ -447,15 +447,19 @@ function SubtitleRemovalPage({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="studio-canvas-ghost"
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<VideoCameraOutlined />
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ScissorOutlined,
|
||||
SwapOutlined,
|
||||
} 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/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -48,6 +48,7 @@ function WatermarkRemovalPage({
|
||||
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
|
||||
const [activeTaskId, setActiveTaskId] = useState("");
|
||||
const [taskProgress, setTaskProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isSavingAsset, setIsSavingAsset] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
@@ -124,6 +125,10 @@ function WatermarkRemovalPage({
|
||||
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 normalizedUrl = sourceUrl.trim();
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||||
@@ -403,17 +408,22 @@ function WatermarkRemovalPage({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="image-workbench-empty image-workbench-empty--button"
|
||||
<div
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleFileDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<DeleteOutlined />
|
||||
<strong>拖拽或选择含水印图片</strong>
|
||||
<span>支持 PNG / JPG / WebP</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传含水印图片后点击"开始去水印"</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
|
||||
@@ -2,24 +2,18 @@ import {
|
||||
AppstoreOutlined,
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CaretRightOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
FullscreenOutlined,
|
||||
LoadingOutlined,
|
||||
MessageOutlined,
|
||||
MutedOutlined,
|
||||
PictureOutlined,
|
||||
PauseOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
StopOutlined,
|
||||
ThunderboltOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
@@ -35,10 +29,10 @@ import {
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import "../../styles/pages/workbench.css";
|
||||
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { claimGenerationSlot, getActiveGenerationTaskCount, getEffectiveGenerationLimit, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
|
||||
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
||||
import { preUploadReference } from "../../api/referenceUploadService";
|
||||
import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import { loadRechargeModal, type RechargeModalComponent } from "../../components/RechargeModal/loadRechargeModal";
|
||||
@@ -46,7 +40,6 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import {
|
||||
@@ -62,8 +55,6 @@ import {
|
||||
MarkdownMessage,
|
||||
ResultCard,
|
||||
} from "./components/WorkbenchChatCards";
|
||||
import { renderMarkdownBlocks } from "./markdownRenderer";
|
||||
import { downloadResultAsset } from "./workbenchDownload";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
@@ -80,7 +71,6 @@ import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import {
|
||||
getImageQualityOptions,
|
||||
getImageQualityOptionsForContext,
|
||||
getDefaultImageQuality,
|
||||
getDefaultImageQualityForContext,
|
||||
@@ -90,8 +80,6 @@ import {
|
||||
} from "../../utils/modelOptions";
|
||||
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
|
||||
import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence";
|
||||
import { SmoothedProgressBar } from "./SmoothedProgressBar";
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import {
|
||||
type WorkbenchMode,
|
||||
type ToolbarMenuId,
|
||||
@@ -99,8 +87,6 @@ import {
|
||||
type WorkbenchOption,
|
||||
type WorkbenchFieldGroup,
|
||||
type ReferenceItem,
|
||||
type PromptMentionItem,
|
||||
type PromptMentionTokenRange,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type DeleteDialogState,
|
||||
@@ -117,33 +103,18 @@ import {
|
||||
GRID_SUPPORTED_MODELS,
|
||||
VIDEO_FRAME_OPTIONS,
|
||||
VIDEO_DURATION_OPTIONS,
|
||||
MESSAGE_STORAGE_KEY,
|
||||
ACTIVE_CONVERSATION_STORAGE_KEY,
|
||||
PROMPT_HISTORY_STORAGE_KEY,
|
||||
TASK_KEEPALIVE_STORAGE_KEY,
|
||||
WORKBENCH_TASK_STALE_MS,
|
||||
WORKBENCH_TASK_MAX_POLL_FAILURES,
|
||||
REFERENCE_IMAGE_COMPRESS_THRESHOLD,
|
||||
REFERENCE_IMAGE_MAX_DIMENSION,
|
||||
REFERENCE_IMAGE_INITIAL_QUALITY,
|
||||
REFERENCE_IMAGE_MIN_QUALITY,
|
||||
CHAT_MODEL,
|
||||
CHAT_NATURAL_SYSTEM_PROMPT,
|
||||
CHAT_TURN_STYLE_REMINDER,
|
||||
NON_CONVERSATIONAL_ASSISTANT_TEXT,
|
||||
getCachedRole,
|
||||
getSessionUserId,
|
||||
userKey,
|
||||
createId,
|
||||
formatWorkbenchTimestamp,
|
||||
parseWorkbenchTimestampValue,
|
||||
buildChatAttachments,
|
||||
buildNaturalChatHistoryMessages,
|
||||
getErrorText,
|
||||
isAuthFailure,
|
||||
isInsufficientBalance,
|
||||
isInsufficientBalanceMessage,
|
||||
isTransientMessage,
|
||||
getPersistableMessages,
|
||||
shouldPersistPatch,
|
||||
buildAssistantResult,
|
||||
@@ -156,25 +127,18 @@ import {
|
||||
persistMessages,
|
||||
clearWorkbenchLocalState,
|
||||
persistPromptHistory,
|
||||
buildRecoverableTaskFromMessage,
|
||||
readStoredKeepaliveTasks,
|
||||
persistKeepaliveTasks,
|
||||
} from "./workbenchStorage";
|
||||
import {
|
||||
getRatioOptionClassName,
|
||||
getSettingsGridColumnsClassName,
|
||||
getReferenceAccept,
|
||||
getReferenceUploadLabel,
|
||||
getReferenceLimit,
|
||||
getReferenceKindLabel,
|
||||
getReferenceEmptyCopy,
|
||||
hexToRgbTriplet,
|
||||
inferReferenceKind,
|
||||
disposeReferencePreview,
|
||||
fileToDataUrl,
|
||||
bytesToHex,
|
||||
buildReferenceFingerprint,
|
||||
canCompressReferenceImage,
|
||||
compressReferenceImageIfNeeded,
|
||||
buildReferenceToken,
|
||||
resolveReferenceUrls,
|
||||
@@ -188,17 +152,19 @@ import {
|
||||
import {
|
||||
findPromptMentionRangeInside,
|
||||
findPromptMentionRangeOverlap,
|
||||
ReferenceInlinePreview,
|
||||
ReferencePreview,
|
||||
PromptPreviewLayer,
|
||||
} from "./WorkbenchPromptPreview";
|
||||
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
|
||||
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
|
||||
|
||||
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
|
||||
|
||||
interface WorkbenchPageProps {
|
||||
isAuthenticated: boolean;
|
||||
session: WebUserSession | null;
|
||||
onboarding?: boolean;
|
||||
onEndOnboarding?: () => void;
|
||||
onRequireLogin: (input: CreatePreviewTaskInput) => void;
|
||||
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
|
||||
onRefreshUsage?: () => void;
|
||||
@@ -231,6 +197,8 @@ function formatCreditValue(value: number): string {
|
||||
function WorkbenchPage({
|
||||
isAuthenticated,
|
||||
session,
|
||||
onboarding,
|
||||
onEndOnboarding,
|
||||
onRequireLogin,
|
||||
onOpenResultInCanvas,
|
||||
onRefreshUsage,
|
||||
@@ -264,7 +232,41 @@ function WorkbenchPage({
|
||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||
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 [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
|
||||
@@ -294,6 +296,34 @@ function WorkbenchPage({
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
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 [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
||||
@@ -427,7 +457,7 @@ function WorkbenchPage({
|
||||
const toolTheme = MODE_META[activeMode];
|
||||
const workbenchAccent = "#00ff88";
|
||||
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
|
||||
const referenceCount = referenceItems.length;
|
||||
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
|
||||
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
|
||||
const activeModelValue =
|
||||
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel;
|
||||
@@ -454,7 +484,6 @@ function WorkbenchPage({
|
||||
[conversations],
|
||||
);
|
||||
const hasSidebarRecords = conversationRecords.length > 0;
|
||||
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
|
||||
|
||||
const activeConversationTitle = useMemo(() => {
|
||||
if (!activeConversationId) return "";
|
||||
@@ -940,7 +969,7 @@ function WorkbenchPage({
|
||||
|
||||
const patchConversationMessage = useCallback(
|
||||
async (conversationId: number, messageId: string, patch: Partial<ChatMessage>) => {
|
||||
let sourceMessages =
|
||||
const sourceMessages =
|
||||
activeConversationIdRef.current === conversationId
|
||||
? messagesRef.current
|
||||
: conversationMessagesCacheRef.current.get(conversationId);
|
||||
@@ -1643,7 +1672,41 @@ function WorkbenchPage({
|
||||
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) => {
|
||||
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);
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen(false);
|
||||
@@ -2135,7 +2198,7 @@ function WorkbenchPage({
|
||||
taskId = result.taskId;
|
||||
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
|
||||
} else {
|
||||
let requestModel = resolveVideoRequestModel({
|
||||
const requestModel = resolveVideoRequestModel({
|
||||
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
referenceUrls: refUrls,
|
||||
});
|
||||
@@ -2873,6 +2936,7 @@ function WorkbenchPage({
|
||||
className="wb-composer__ref-upload"
|
||||
onClick={handleReferenceUploadClick}
|
||||
disabled={disabled}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
|
||||
aria-label={`上传${referenceUploadLabel}`}
|
||||
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
|
||||
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
|
||||
@@ -2929,9 +2993,10 @@ function WorkbenchPage({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderComposerToolbar = (disabled = false, showStop = false) => (
|
||||
const renderComposerToolbar = (disabled = false, _showStop = false) => (
|
||||
<div className="wb-composer__toolbar">
|
||||
<div className="wb-composer__toolbar-left">
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
|
||||
<SelectChip
|
||||
chipId="studio-mode"
|
||||
value={activeMode}
|
||||
@@ -2944,8 +3009,10 @@ function WorkbenchPage({
|
||||
ariaLabel="工作台模式"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
{activeMode === "chat" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
|
||||
<SelectChip
|
||||
chipId="chat-model"
|
||||
value={chatModel}
|
||||
@@ -2958,6 +3025,8 @@ function WorkbenchPage({
|
||||
ariaLabel="对话模型"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
|
||||
<SelectChip
|
||||
chipId="chat-speed"
|
||||
value={thinkingSpeed}
|
||||
@@ -2970,6 +3039,8 @@ function WorkbenchPage({
|
||||
ariaLabel="思考速度"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
|
||||
<SelectChip
|
||||
chipId="chat-depth"
|
||||
value={thinkingDepth}
|
||||
@@ -2982,10 +3053,12 @@ function WorkbenchPage({
|
||||
ariaLabel="思考深度"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{activeMode === "image" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
|
||||
<SelectChip
|
||||
chipId="image-model"
|
||||
value={imageModel}
|
||||
@@ -2997,6 +3070,8 @@ function WorkbenchPage({
|
||||
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
|
||||
<CompoundSelectChip
|
||||
chipId="image-settings"
|
||||
summary={imageSettingsSummary}
|
||||
@@ -3006,7 +3081,9 @@ function WorkbenchPage({
|
||||
onToggle={() => toggleToolbarMenu("image-settings")}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
|
||||
<SelectChip
|
||||
chipId="image-grid-mode"
|
||||
value={imageGridMode}
|
||||
@@ -3018,11 +3095,13 @@ function WorkbenchPage({
|
||||
onChange={setImageGridMode}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeMode === "video" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
|
||||
<SelectChip
|
||||
chipId="video-model"
|
||||
value={videoModel}
|
||||
@@ -3034,6 +3113,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoModel}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
|
||||
<SelectChip
|
||||
chipId="video-mode"
|
||||
value={videoFrameMode}
|
||||
@@ -3045,6 +3126,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoFrameMode}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
|
||||
<CompoundSelectChip
|
||||
chipId="video-ratio"
|
||||
summary={videoRatio}
|
||||
@@ -3054,6 +3137,8 @@ function WorkbenchPage({
|
||||
onToggle={() => toggleToolbarMenu("video-ratio")}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
|
||||
<InlineOptionChip
|
||||
chipId="video-duration"
|
||||
value={videoDuration}
|
||||
@@ -3066,6 +3151,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoDuration}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
|
||||
<InlineOptionChip
|
||||
chipId="video-quality"
|
||||
value={videoQuality}
|
||||
@@ -3078,6 +3165,7 @@ function WorkbenchPage({
|
||||
onChange={setVideoQuality}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -3091,6 +3179,7 @@ function WorkbenchPage({
|
||||
disabled={sendDisabled || isGenerating}
|
||||
title={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
|
||||
onClick={() => {
|
||||
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
|
||||
mode: activeMode,
|
||||
@@ -3249,6 +3338,7 @@ function WorkbenchPage({
|
||||
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
|
||||
placeholder={composerPlaceholder}
|
||||
value={inputValue}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||
onChange={handlePromptChange}
|
||||
onSelect={handlePromptSelectionChange}
|
||||
onKeyUp={handlePromptSelectionChange}
|
||||
@@ -3314,6 +3404,14 @@ function WorkbenchPage({
|
||||
{renderMessagePreviewOverlay()}
|
||||
{renderPromptCaseOverlay()}
|
||||
{renderDeleteDialog()}
|
||||
<OnboardingTour
|
||||
active={Boolean(effectiveOnboarding)}
|
||||
phase={tourPhase}
|
||||
stepIndex={tourStep}
|
||||
onNext={handleTourNext}
|
||||
onSkip={handleTourSkip}
|
||||
onDone={handleTourDone}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3435,6 +3533,7 @@ function WorkbenchPage({
|
||||
placeholder={composerPlaceholder}
|
||||
value={inputValue}
|
||||
disabled={false}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||
onChange={handlePromptChange}
|
||||
onSelect={handlePromptSelectionChange}
|
||||
onKeyUp={handlePromptSelectionChange}
|
||||
@@ -3482,6 +3581,15 @@ function WorkbenchPage({
|
||||
{showRechargeModal && RechargeModal ? (
|
||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||
) : null}
|
||||
|
||||
<OnboardingTour
|
||||
active={Boolean(effectiveOnboarding)}
|
||||
phase={tourPhase}
|
||||
stepIndex={tourStep}
|
||||
onNext={handleTourNext}
|
||||
onSkip={handleTourSkip}
|
||||
onDone={handleTourDone}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,14 +149,14 @@ export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||
|
||||
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "ultra", label: "急速" },
|
||||
{ value: "high", label: "思考速度:高" },
|
||||
{ value: "ultra", label: "思考速度:急速" },
|
||||
];
|
||||
|
||||
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "strong", label: "强" },
|
||||
{ value: "extreme", label: "极限" },
|
||||
{ value: "strong", label: "推理深度:强" },
|
||||
{ value: "extreme", label: "推理深度:极限" },
|
||||
];
|
||||
|
||||
export const CHAT_NATURAL_SYSTEM_PROMPT = [
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type WorkbenchMode,
|
||||
type ReferenceKind,
|
||||
type ReferenceItem,
|
||||
type WorkbenchOption,
|
||||
} from "./workbenchConstants";
|
||||
import { resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
||||
|
||||
@@ -82,7 +81,11 @@ export function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read reference file"));
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Unable to read reference file"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read reference file"));
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { create } from "zustand";
|
||||
import type { WebGenerationPreviewTask } from "../types";
|
||||
|
||||
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
@@ -63,17 +62,6 @@ interface GenerationStoreState {
|
||||
clearTerminal: () => void;
|
||||
}
|
||||
|
||||
function hashUserId(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem("omniai-web-session");
|
||||
if (!raw) return "anon";
|
||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||
return String(parsed?.user?.id || "anon");
|
||||
} catch {
|
||||
return "anon";
|
||||
}
|
||||
}
|
||||
|
||||
const initialQueue = loadPersistedQueue();
|
||||
|
||||
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1542,9 +1542,7 @@
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 14px;
|
||||
@@ -1874,11 +1872,8 @@
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-module-panel {
|
||||
display: grid;
|
||||
flex: 0 0 272px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
height: 272px;
|
||||
min-height: 0;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 14px;
|
||||
background: #1c1f26;
|
||||
@@ -1906,25 +1901,6 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
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 {
|
||||
@@ -1981,11 +1957,8 @@
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-model-panel {
|
||||
display: grid;
|
||||
flex: 0 0 272px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
height: 272px;
|
||||
min-height: 0;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 14px;
|
||||
background: #1c1f26;
|
||||
@@ -2032,25 +2005,7 @@
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll {
|
||||
min-height: 0;
|
||||
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;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-model-scenes,
|
||||
@@ -2223,16 +2178,12 @@
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 8px;
|
||||
background: #22252d;
|
||||
background-image: none;
|
||||
padding: 5px;
|
||||
box-shadow: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3d4552 #171a20;
|
||||
transform-origin: top center;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2351,31 +2288,14 @@
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-video-panel {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
border: 1px solid #303540;
|
||||
border-radius: 14px;
|
||||
background: #1c1f26;
|
||||
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 {
|
||||
@@ -8611,31 +8531,86 @@
|
||||
transition: none;
|
||||
}
|
||||
.clone-ai-video-outfit-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.clone-ai-video-outfit-upload-btn {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--fg-body);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
flex-direction: column;
|
||||
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-weight: 850;
|
||||
line-height: 1.35;
|
||||
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 {
|
||||
border-color: var(--border-default);
|
||||
background: var(--bg-hover);
|
||||
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.48);
|
||||
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 {
|
||||
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;
|
||||
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. */
|
||||
|
||||
+533
-15
@@ -402,21 +402,6 @@
|
||||
transform: translateZ(20px) scale(1.02);
|
||||
}
|
||||
|
||||
.omni-home__carousel-card-label {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 14px;
|
||||
z-index: 2;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb, 0, 255, 136), 0.16);
|
||||
border: 1px solid rgba(var(--accent-rgb, 0, 255, 136), 0.24);
|
||||
color: var(--fg-body, #f3f5f2);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-home__carousel-card:hover {
|
||||
box-shadow:
|
||||
0 28px 58px rgb(0 0 0 / 34%),
|
||||
@@ -2479,3 +2464,536 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Home page product polish and responsive hardening ===== */
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
--home-card-radius: 14px;
|
||||
--home-panel-radius: 18px;
|
||||
--home-safe-inline: clamp(16px, 4.6vw, 72px);
|
||||
scroll-padding-top: 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 8, 13, 0.82), rgba(5, 8, 13, 0.52) 38%, rgba(5, 8, 13, 0.9)),
|
||||
radial-gradient(circle at 50% 24%, rgba(var(--accent-rgb), 0.1), transparent 36%),
|
||||
linear-gradient(90deg, rgba(5, 8, 13, 0.86), rgba(5, 8, 13, 0.48) 48%, rgba(5, 8, 13, 0.86));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
padding: clamp(28px, 4.4vw, 58px) var(--home-safe-inline) clamp(34px, 4.8vw, 70px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
width: min(100%, 1240px);
|
||||
gap: clamp(16px, 2vw, 24px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy h1 {
|
||||
max-width: min(100%, 920px);
|
||||
white-space: normal;
|
||||
text-wrap: balance;
|
||||
font-size: clamp(34px, 4vw, 62px);
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 18px 54px rgb(0 0 0 / 34%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy p {
|
||||
max-width: 680px;
|
||||
color: rgb(235 245 240 / 72%);
|
||||
font-size: clamp(14px, 1.25vw, 18px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
width: min(100%, 620px);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
min-height: 64px;
|
||||
border-color: rgb(255 255 255 / 11%);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 7%), rgb(255 255 255 / 3%)),
|
||||
rgb(12 16 17 / 78%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%),
|
||||
0 16px 34px rgb(0 0 0 / 22%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry > span:not(.anticon) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry small {
|
||||
color: rgb(235 245 240 / 48%);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary small {
|
||||
color: rgb(6 16 20 / 62%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry:hover {
|
||||
border-color: rgb(0 255 136 / 34%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)),
|
||||
rgb(17 22 22 / 88%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary,
|
||||
.web-shell[data-view="home"] .omni-home__entry--primary:hover {
|
||||
border-color: rgb(0 255 136 / 82%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(88 255 172 / 100%), rgb(0 255 136 / 100%));
|
||||
box-shadow:
|
||||
0 18px 42px rgb(0 255 136 / 14%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
width: min(100%, 1180px);
|
||||
min-height: clamp(380px, 36vw, 560px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 1180px);
|
||||
height: clamp(360px, 34vw, 530px);
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 5%, #000 95%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 5%, #000 95%, transparent 100%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: clamp(500px, 45vw, 760px);
|
||||
height: clamp(281px, 25.31vw, 428px);
|
||||
border: 1px solid rgb(255 255 255 / 5%);
|
||||
border-radius: var(--home-panel-radius);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), rgb(0 0 0 / 5%)),
|
||||
#07100f;
|
||||
box-shadow:
|
||||
0 18px 46px rgb(0 0 0 / 30%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card::before {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(2 12 12 / 18%), transparent 34%, rgb(3 9 10 / 30%)),
|
||||
linear-gradient(90deg, rgb(4 10 12 / 38%), transparent 24%, transparent 76%, rgb(4 10 12 / 38%)),
|
||||
radial-gradient(circle at 50% 58%, rgb(0 255 136 / 8%), transparent 54%);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(255 255 255 / 4%),
|
||||
inset 0 -42px 64px rgb(2 8 9 / 32%),
|
||||
inset 0 34px 56px rgb(255 255 255 / 4%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card.is-active {
|
||||
border-color: rgb(255 255 255 / 7%);
|
||||
box-shadow:
|
||||
0 26px 70px rgb(0 0 0 / 38%),
|
||||
0 0 0 1px rgb(255 255 255 / 4%),
|
||||
0 0 46px rgb(0 0 0 / 18%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card:not(.is-active) {
|
||||
filter: saturate(0.62) brightness(0.66) contrast(0.96);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card img,
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card.is-active img {
|
||||
object-fit: contain;
|
||||
transform: translateZ(12px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card img {
|
||||
filter:
|
||||
saturate(0.72)
|
||||
contrast(0.98)
|
||||
brightness(0.72)
|
||||
hue-rotate(8deg)
|
||||
drop-shadow(0 18px 18px rgb(0 0 0 / 18%));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage .omni-home__carousel-card.is-active img {
|
||||
filter:
|
||||
saturate(0.9)
|
||||
contrast(1.03)
|
||||
brightness(0.88)
|
||||
hue-rotate(6deg)
|
||||
drop-shadow(0 22px 22px rgb(0 0 0 / 18%));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page {
|
||||
padding-inline: var(--home-safe-inline);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page::before {
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 8 13 / 96%) 0%, rgb(5 8 13 / 80%) 40%, rgb(5 8 13 / 52%) 100%),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 34%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-visual {
|
||||
border-radius: var(--home-panel-radius);
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 2%)),
|
||||
#0d1111;
|
||||
box-shadow:
|
||||
0 24px 68px rgb(0 0 0 / 34%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy h2,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy h2 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy p,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy p {
|
||||
color: rgb(232 238 236 / 74%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce {
|
||||
padding-inline: clamp(10px, 1.8vw, 28px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
background:
|
||||
linear-gradient(112deg, rgb(0 255 136 / 10%) 0 1px, transparent 1px 20%),
|
||||
linear-gradient(68deg, rgb(0 255 136 / 7%) 0 1px, transparent 1px 22%),
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route {
|
||||
border-radius: var(--home-card-radius);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(
|
||||
.omni-home__shell,
|
||||
.omni-home__feature-page,
|
||||
.omni-home__toolbox-page,
|
||||
.omni-home__experience
|
||||
) {
|
||||
scroll-margin-top: 64px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent {
|
||||
right: clamp(14px, 2vw, 24px);
|
||||
bottom: 12px;
|
||||
width: min(560px, calc(100vw - 28px));
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-color: rgb(0 255 136 / 20%);
|
||||
border-radius: 13px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 3%)),
|
||||
rgb(13 17 18 / 92%);
|
||||
box-shadow:
|
||||
0 18px 46px rgb(0 0 0 / 30%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent p {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.32;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions a,
|
||||
.web-shell[data-view="home"] .cookie-consent__actions button {
|
||||
min-height: 32px;
|
||||
padding-inline: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(340px, 42vw, 500px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 1020px);
|
||||
height: clamp(320px, 39vw, 470px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: clamp(470px, 57vw, 660px);
|
||||
height: clamp(264px, 32.06vw, 371px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
scroll-snap-type: none;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
min-height: auto;
|
||||
padding-top: clamp(24px, 4vw, 38px);
|
||||
padding-bottom: clamp(34px, 6vw, 58px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
min-height: 58px;
|
||||
padding-inline: 18px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
width: min(100%, 820px);
|
||||
min-height: clamp(290px, 54vw, 430px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
width: min(100%, 820px);
|
||||
height: clamp(280px, 51vw, 410px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page,
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
min-height: auto;
|
||||
padding-block: clamp(44px, 7vw, 72px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy h2,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy h2 {
|
||||
font-size: clamp(34px, 7vw, 58px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-copy p,
|
||||
.web-shell[data-view="home"] .omni-home__experience-copy p {
|
||||
font-size: clamp(15px, 2.4vw, 20px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
height: auto;
|
||||
min-height: clamp(520px, 78vw, 720px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-visual {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-view="home"] .omni-home__copy h1 {
|
||||
font-size: clamp(30px, 9vw, 44px);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy p {
|
||||
max-width: 520px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__actions {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry {
|
||||
justify-content: center;
|
||||
min-height: 54px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__entry > span:not(.anticon) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(260px, 70vw, 360px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
height: clamp(248px, 66vw, 340px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: min(94vw, 520px);
|
||||
height: min(52.88vw, 292px);
|
||||
min-height: 206px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-stats {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
margin-top: 18px;
|
||||
max-width: none;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-routes {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route {
|
||||
min-height: 84px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-route b {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.web-shell[data-view="home"] .omni-home {
|
||||
--home-safe-inline: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__shell {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__hero {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__copy {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel {
|
||||
min-height: clamp(228px, 68vw, 286px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-stage {
|
||||
height: clamp(218px, 64vw, 274px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-deck {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__carousel-card {
|
||||
width: 94vw;
|
||||
height: 52.88vw;
|
||||
min-height: 188px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page,
|
||||
.web-shell[data-view="home"] .omni-home__experience {
|
||||
padding-block: 34px 44px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(
|
||||
.omni-home__shell,
|
||||
.omni-home__feature-page,
|
||||
.omni-home__toolbox-page,
|
||||
.omni-home__experience
|
||||
) {
|
||||
scroll-margin-top: 60px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-script .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-model .omni-home__feature-visual,
|
||||
.web-shell[data-view="home"] .omni-home__feature-page.is-ecommerce .omni-home__feature-visual {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__experience-routes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent {
|
||||
right: 10px;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
grid-template-columns: 1fr;
|
||||
width: calc(100vw - 20px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: initial;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .cookie-consent__actions a,
|
||||
.web-shell[data-view="home"] .cookie-consent__actions button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
+311
-117
@@ -1,6 +1,11 @@
|
||||
.more-page-v2 {
|
||||
--more-card-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
|
||||
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.025), 0 18px 38px rgba(0, 0, 0, 0.16);
|
||||
--more-card-shadow: 0 22px 54px rgba(0, 0, 0, 0.3);
|
||||
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.035), 0 16px 34px rgba(0, 0, 0, 0.18);
|
||||
--more-card-surface: rgba(19, 23, 24, 0.86);
|
||||
--more-card-surface-strong: rgba(22, 27, 28, 0.94);
|
||||
--more-card-border: rgba(255, 255, 255, 0.105);
|
||||
--more-card-border-strong: rgba(var(--accent-rgb), 0.3);
|
||||
--more-page-pad-x: clamp(18px, 2.3vw, 32px);
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -158,24 +163,24 @@
|
||||
|
||||
.more-page-v2__scroll {
|
||||
overflow-y: auto;
|
||||
padding: 26px 28px 68px;
|
||||
padding: 28px var(--more-page-pad-x) 72px;
|
||||
scrollbar-color: rgba(var(--accent-rgb), 0.26) transparent;
|
||||
}
|
||||
|
||||
.more-page-v2__section {
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.more-page-v2__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 15px;
|
||||
color: var(--fg-muted);
|
||||
margin: 0 0 14px;
|
||||
color: color-mix(in srgb, var(--fg-muted) 86%, var(--fg-body));
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.055em;
|
||||
}
|
||||
|
||||
.more-page-v2__section-title .anticon {
|
||||
@@ -199,27 +204,31 @@
|
||||
|
||||
.more-page-v2__recent-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.more-page-v2__featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
justify-items: stretch;
|
||||
gap: 12px;
|
||||
min-height: 336px;
|
||||
padding: 20px;
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
border-color: rgba(var(--accent-rgb), 0.2);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
var(--card-gradient),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 14% 4%, rgba(var(--accent-rgb), 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.016)),
|
||||
var(--more-card-surface-strong);
|
||||
box-shadow: var(--more-card-glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -230,42 +239,52 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.035), transparent),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 34%);
|
||||
opacity: 0.5;
|
||||
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.038), transparent),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 34%);
|
||||
opacity: 0.62;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.more-card--featured:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.45);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
var(--bg-inset);
|
||||
color: var(--accent);
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(var(--accent-rgb), 0.46);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.more-card__featured-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
gap: 10px;
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview-frame img {
|
||||
padding: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview .more-card__featured-body {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.more-card--featured.more-card--no-preview .more-card__outcome {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.more-card__featured-kicker {
|
||||
width: fit-content;
|
||||
color: var(--accent);
|
||||
@@ -277,14 +296,14 @@
|
||||
|
||||
.more-card__featured-body strong {
|
||||
color: var(--fg-body);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
font-weight: 850;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.more-card__featured-desc {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -327,20 +346,23 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
margin-top: 0;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
min-height: 32px;
|
||||
margin-top: auto;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.34);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.06);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
color: var(--accent) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -350,18 +372,22 @@
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
min-height: 392px;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border-weak);
|
||||
border: 1px solid var(--more-card-border);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 42%),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.055), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 42%),
|
||||
var(--more-card-surface);
|
||||
color: var(--fg-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.035),
|
||||
0 1px 0 rgba(255, 255, 255, 0.02);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
@@ -370,12 +396,19 @@
|
||||
}
|
||||
|
||||
.more-card:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.38);
|
||||
border-color: var(--more-card-border-strong);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 46%),
|
||||
var(--bg-hover, rgba(255, 255, 255, 0.03));
|
||||
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.085), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 46%),
|
||||
rgba(24, 29, 30, 0.94);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--more-card-glow), 0 10px 26px rgba(0, 0, 0, 0.16);
|
||||
box-shadow: var(--more-card-glow), 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.more-card:active,
|
||||
.more-page-v2__filters button:active,
|
||||
.more-page-v2__empty-action:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.more-card--pending {
|
||||
@@ -395,17 +428,20 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 150px;
|
||||
min-height: 54px;
|
||||
padding: 10px 14px;
|
||||
border-color: rgba(var(--accent-rgb), 0.14);
|
||||
min-width: 164px;
|
||||
min-height: 58px;
|
||||
padding: 11px 14px;
|
||||
border-color: rgba(var(--accent-rgb), 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.016)),
|
||||
rgba(18, 23, 24, 0.88);
|
||||
}
|
||||
|
||||
.more-card__icon {
|
||||
display: grid;
|
||||
display: none;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.16);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
@@ -417,6 +453,7 @@
|
||||
}
|
||||
|
||||
.more-card--recent .more-card__icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
@@ -437,15 +474,15 @@
|
||||
.more-card strong {
|
||||
max-width: 100%;
|
||||
color: var(--fg-body);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.more-card__topline {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
@@ -472,9 +509,9 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
min-height: 104px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.24);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.1), transparent 34%),
|
||||
@@ -483,8 +520,7 @@
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
|
||||
0 0 20px rgba(var(--accent-rgb), 0.08);
|
||||
clip-path: polygon(0 10px, 10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%);
|
||||
0 0 18px rgba(var(--accent-rgb), 0.07);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -506,7 +542,6 @@
|
||||
inset: 5px;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.16);
|
||||
clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -880,15 +915,102 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.more-card__preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.42 / 1;
|
||||
overflow: visible;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.more-card__preview-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(var(--accent-rgb), 0.12), transparent 56%),
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.08), transparent 34%),
|
||||
var(--bg-inset);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
0 0 18px rgba(var(--accent-rgb), 0.06);
|
||||
}
|
||||
|
||||
.more-card__preview-frame::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 34%, rgba(0, 0, 0, 0.18)),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.045), transparent 38%, rgba(255, 255, 255, 0.025));
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.more-card__preview-frame img,
|
||||
.more-card__preview-popover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
transform: none;
|
||||
transition:
|
||||
filter 220ms ease;
|
||||
}
|
||||
|
||||
.more-card:hover .more-card__preview-frame img {
|
||||
filter: saturate(1.05) contrast(1.02);
|
||||
}
|
||||
|
||||
.more-card__preview-popover {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
z-index: 20;
|
||||
width: min(420px, calc(100vw - 48px));
|
||||
height: auto;
|
||||
max-height: min(360px, 58vh);
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.34);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 20%, rgba(var(--accent-rgb), 0.12), transparent 52%),
|
||||
rgba(10, 14, 14, 0.96);
|
||||
box-shadow:
|
||||
0 28px 68px rgba(0, 0, 0, 0.46),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px) scale(0.96);
|
||||
transform-origin: 50% 100%;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.more-card__preview:hover .more-card__preview-popover {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
.more-card--featured .more-card__preview-popover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more-card__desc {
|
||||
color: var(--fg-muted);
|
||||
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
display: block;
|
||||
min-height: 38px;
|
||||
min-height: 50px;
|
||||
color: color-mix(in srgb, var(--fg-muted) 78%, var(--fg-body));
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
@@ -898,15 +1020,15 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 26px;
|
||||
margin-top: 2px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-height: 30px;
|
||||
margin-top: auto;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--fg-body);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
@@ -915,8 +1037,8 @@
|
||||
}
|
||||
|
||||
.more-card:hover .more-card__action {
|
||||
border-color: rgba(var(--accent-rgb), 0.28);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
border-color: rgba(var(--accent-rgb), 0.32);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
color: var(--accent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
@@ -936,14 +1058,15 @@
|
||||
.more-page-v2__empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
min-height: 220px;
|
||||
padding: 34px 20px;
|
||||
border: 1px solid var(--border-weak);
|
||||
gap: 12px;
|
||||
min-height: 238px;
|
||||
padding: 38px 22px;
|
||||
border: 1px solid var(--more-card-border);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.065), transparent 64%),
|
||||
var(--bg-panel);
|
||||
radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), 0.1), transparent 42%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 64%),
|
||||
var(--more-card-surface);
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -951,11 +1074,13 @@
|
||||
.more-page-v2__empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -978,12 +1103,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
min-height: 36px;
|
||||
margin-top: 4px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.32);
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.36);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--accent-rgb), 0.14), rgba(var(--accent-rgb), 0.08)),
|
||||
rgba(var(--accent-rgb), 0.06);
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
@@ -1013,6 +1140,7 @@
|
||||
|
||||
.more-page-v2__header {
|
||||
grid-template-columns: minmax(180px, auto) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__filters {
|
||||
@@ -1023,15 +1151,21 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.more-page-v2 {
|
||||
--more-page-pad-x: 16px;
|
||||
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.more-page-v2__header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 14px 16px 12px;
|
||||
padding: 16px 16px 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-page-v2__header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1047,13 +1181,22 @@
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.more-page-v2__filters button {
|
||||
min-height: 31px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.more-page-v2__scroll {
|
||||
padding: 16px 16px 48px;
|
||||
padding: 18px 16px 52px;
|
||||
}
|
||||
|
||||
.more-page-v2__section {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__recent-row {
|
||||
@@ -1063,23 +1206,24 @@
|
||||
}
|
||||
|
||||
.more-page-v2__featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
.more-card__featured-body strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.more-card__featured-body strong {
|
||||
font-size: 15px;
|
||||
.more-card--featured .more-card__preview {
|
||||
width: 100%;
|
||||
min-height: 176px;
|
||||
}
|
||||
|
||||
.more-card__featured-kicker,
|
||||
@@ -1097,8 +1241,13 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.more-card__compare {
|
||||
min-height: 82px;
|
||||
.more-card__preview {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
min-height: 394px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.more-card__topline {
|
||||
@@ -1108,24 +1257,69 @@
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
min-height: 54px;
|
||||
min-height: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.more-page-v2__header {
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
margin-right: -16px;
|
||||
padding-right: 16px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.more-page-v2__header-meta::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
gap: 9px;
|
||||
.more-page-v2__featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.more-card__compare {
|
||||
min-height: 94px;
|
||||
.more-page-v2__section-title {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.more-card {
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.more-card__preview {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.more-card__use-case {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.more-card__action,
|
||||
.more-card__cta {
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.more-card__preview-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,12 +684,6 @@
|
||||
}
|
||||
|
||||
@container (max-width: 1120px) {
|
||||
.omni-script-review-showcase {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.srs-left-panel {
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 16px;
|
||||
@@ -711,13 +705,32 @@
|
||||
}
|
||||
|
||||
.srs-results-panel {
|
||||
grid-template-rows: auto minmax(320px, auto) auto;
|
||||
grid-template-rows: auto minmax(300px, auto) auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: 22px;
|
||||
@container (max-width: 880px) {
|
||||
.srs-brand-section {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.srs-brand-section h1 {
|
||||
max-width: 100%;
|
||||
font-size: clamp(28px, 8cqw, 44px);
|
||||
}
|
||||
|
||||
.srs-brand-section p {
|
||||
font-size: clamp(14px, 3.2cqw, 17px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.srs-point-card {
|
||||
min-height: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srs-results-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.srs-score-hero {
|
||||
@@ -729,33 +742,118 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.srs-chart-body,
|
||||
.srs-triple-section,
|
||||
.srs-point-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: minmax(70px, 92px) minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.srs-chart-col-label {
|
||||
text-align: left;
|
||||
.srs-score-summary {
|
||||
font-size: clamp(13px, 3cqw, 16px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.omni-script-review-showcase {
|
||||
@container (max-width: 720px) {
|
||||
.srs-chart-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.srs-chart-title {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.srs-chart-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: minmax(94px, 0.36fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
min-height: 46px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-bg,
|
||||
.srs-chart-bar-fill {
|
||||
width: 100%;
|
||||
height: 100% !important;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-bg {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.srs-chart-bar-fill {
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.srs-chart-col:nth-child(1) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(2) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(3) .srs-chart-bar-fill { width: 100%; }
|
||||
.srs-chart-col:nth-child(4) .srs-chart-bar-fill { width: 80%; }
|
||||
.srs-chart-col:nth-child(5) .srs-chart-bar-fill { width: 66%; }
|
||||
.srs-chart-col:nth-child(6) .srs-chart-bar-fill { width: 53%; }
|
||||
|
||||
.srs-chart-bar-score {
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
font-size: 12px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.srs-chart-bar-sub,
|
||||
.srs-chart-bar-star {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.srs-chart-col-label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.srs-chart-col-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.srs-chart-col-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.srs-triple-section {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.srs-section-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.srs-section-item-text {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
-webkit-line-clamp: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.srs-flow-card {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
@@ -769,4 +867,45 @@
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.srs-score-tags {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.srs-score-tag {
|
||||
min-height: 24px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.srs-chart-col {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.srs-chart-bar-wrap,
|
||||
.srs-chart-col-label {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.omni-script-review-showcase {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(16px, 2.8vw, 24px);
|
||||
overflow-y: visible;
|
||||
padding: clamp(20px, 3vw, 34px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: clamp(16px, 3.8vw, 24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.omni-script-review-showcase {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,12 +1620,23 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.script-eval-v5-illustration:hover {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -299,6 +299,25 @@
|
||||
gap: 10px;
|
||||
padding: 32px;
|
||||
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 {
|
||||
|
||||
@@ -889,3 +889,252 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Home toolbox polish and responsive hardening ===== */
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-page {
|
||||
--toolbox-radius-card: 16px;
|
||||
--toolbox-radius-inner: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%),
|
||||
radial-gradient(ellipse 70% 48% at 58% 42%, rgba(0, 255, 136, 0.045) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
width: min(100%, 1440px);
|
||||
margin-inline: auto;
|
||||
padding: clamp(34px, 5vw, 64px) clamp(18px, 5vw, 72px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: clamp(300px, 28vw, 420px);
|
||||
gap: 14px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 13px;
|
||||
box-shadow: 0 16px 32px rgba(0, 255, 136, 0.12);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon .anticon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
|
||||
font-size: clamp(24px, 2.3vw, 32px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-title {
|
||||
color: #f7fff9;
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
font-size: clamp(32px, 3.4vw, 46px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
|
||||
color: rgba(232, 238, 236, 0.68);
|
||||
font-size: clamp(15px, 1.18vw, 17px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.028)),
|
||||
rgba(10, 15, 16, 0.78);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.075),
|
||||
0 18px 42px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item:hover {
|
||||
border-color: rgba(0, 255, 136, 0.24);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 11px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(232, 238, 236, 0.48);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
|
||||
margin-top: 4px;
|
||||
border-radius: 14px;
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
gap: 14px;
|
||||
min-height: clamp(500px, 48vw, 680px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
border-radius: var(--toolbox-radius-card);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 255, 136, 0.24);
|
||||
box-shadow:
|
||||
0 22px 54px rgba(0, 0, 0, 0.28),
|
||||
0 0 0 1px rgba(0, 255, 136, 0.07);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
|
||||
padding: 15px 16px 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
|
||||
padding: 10px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
|
||||
padding: 7px 16px 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] :is(.toolbox-card1-side, .toolbox-card3-side, .toolbox-card4-side),
|
||||
.web-shell[data-view="home"] :is(.toolbox-card1-img, .toolbox-card3-portrait, .toolbox-card4-img),
|
||||
.web-shell[data-view="home"] .toolbox-card2-frame {
|
||||
border-radius: var(--toolbox-radius-inner);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: clamp(280px, 32vw, 360px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
min-height: clamp(460px, 58vw, 620px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-page {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
min-height: auto;
|
||||
padding-block: clamp(42px, 7vw, 64px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-left {
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: clamp(230px, 34vw, 300px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-shell {
|
||||
padding-inline: 14px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-title {
|
||||
font-size: clamp(26px, 7vw, 34px);
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-list,
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: 236px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-workflow-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
+284
-37
@@ -443,23 +443,23 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal {
|
||||
align-items: stretch;
|
||||
padding: calc(56px + env(safe-area-inset-top, 0px)) 0 0;
|
||||
align-items: center;
|
||||
padding: calc(56px + env(safe-area-inset-top, 0px) + 10px) 12px 12px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 34%, rgba(var(--accent-rgb), 0.12), transparent 42%),
|
||||
rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__panel {
|
||||
--prompt-case-modal-max-height: calc(100svh - 56px - env(safe-area-inset-top, 0px));
|
||||
--prompt-case-modal-max-height: calc(100svh - 56px - env(safe-area-inset-top, 0px) - 22px);
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(210px, 34%);
|
||||
width: 100%;
|
||||
grid-template-rows: minmax(0, 60%) minmax(220px, 40%);
|
||||
width: min(100%, 520px);
|
||||
height: var(--prompt-case-modal-max-height);
|
||||
max-height: var(--prompt-case-modal-max-height);
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.34);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)),
|
||||
rgba(5, 8, 10, 0.96);
|
||||
@@ -467,6 +467,7 @@
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__media {
|
||||
min-height: 0;
|
||||
align-content: center;
|
||||
padding: 14px 14px 8px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
@@ -479,7 +480,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
box-shadow:
|
||||
@@ -490,12 +491,12 @@
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__panel,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__panel {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(220px, 32%);
|
||||
grid-template-rows: minmax(0, 62%) minmax(220px, 38%);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__media,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__media {
|
||||
padding: 12px 14px 8px;
|
||||
padding: 14px 14px 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__media img,
|
||||
@@ -510,35 +511,36 @@
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
gap: 11px;
|
||||
overflow-y: auto;
|
||||
margin-top: -16px;
|
||||
padding: 20px 16px 16px;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: -14px;
|
||||
padding: 18px 16px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-left: 0;
|
||||
border-radius: 24px 24px 0 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
|
||||
rgba(12, 17, 19, 0.99);
|
||||
box-shadow: 0 -18px 38px rgba(0, 0, 0, 0.24);
|
||||
box-shadow: 0 -18px 38px rgba(0, 0, 0, 0.28);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__sidebar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__sidebar {
|
||||
margin-top: -16px;
|
||||
padding: 20px 16px 16px;
|
||||
margin-top: -14px;
|
||||
padding: 18px 16px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-left: 0;
|
||||
border-radius: 24px 24px 0 0;
|
||||
box-shadow: 0 -18px 38px rgba(0, 0, 0, 0.26);
|
||||
box-shadow: 0 -18px 38px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__close {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 3;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(8, 10, 11, 0.72);
|
||||
color: rgba(243, 245, 242, 0.78);
|
||||
@@ -546,29 +548,38 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author {
|
||||
gap: 10px;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 13px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta {
|
||||
gap: 7px;
|
||||
padding-bottom: 11px;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta h2 {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta p,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-prompt p {
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-long-copy .wb-prompt-case-meta p {
|
||||
@@ -579,17 +590,17 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-prompt {
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
max-height: 132px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-long-copy .wb-prompt-case-prompt {
|
||||
max-height: 118px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-prompt,
|
||||
@@ -608,13 +619,15 @@
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-actions button {
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-actions,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-actions {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,14 +638,15 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__panel {
|
||||
grid-template-rows: minmax(0, 1fr) minmax(230px, 38%);
|
||||
border-radius: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 58%) minmax(230px, 42%);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__panel,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__panel {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(230px, 38%);
|
||||
grid-template-rows: minmax(0, 60%) minmax(230px, 40%);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__media {
|
||||
@@ -1689,6 +1703,239 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal {
|
||||
align-items: center;
|
||||
padding: calc(56px + env(safe-area-inset-top, 0px) + 10px) 10px 12px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 28%, rgba(var(--accent-rgb), 0.12), transparent 42%),
|
||||
rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__panel {
|
||||
--prompt-case-modal-max-height: min(560px, calc(100svh - 56px - env(safe-area-inset-top, 0px) - 22px));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(188px, 0.92fr);
|
||||
grid-template-rows: 1fr;
|
||||
width: min(calc(100vw - 20px), 660px);
|
||||
height: var(--prompt-case-modal-max-height);
|
||||
max-height: var(--prompt-case-modal-max-height);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.32);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)),
|
||||
rgba(5, 8, 10, 0.97);
|
||||
box-shadow:
|
||||
0 24px 64px rgba(0, 0, 0, 0.48),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__panel,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__panel {
|
||||
grid-template-columns: minmax(0, 0.96fr) minmax(190px, 1.04fr);
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__media,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__media,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__media {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), 0.11), transparent 42%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 58%),
|
||||
rgba(4, 8, 13, 0.98);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__media img,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__media img,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(0, 0, 0, 0.32),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__sidebar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__sidebar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__sidebar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
align-self: stretch;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
gap: 9px;
|
||||
overflow: hidden;
|
||||
margin-top: 0;
|
||||
padding: 16px 14px 14px;
|
||||
border: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.018)),
|
||||
rgba(12, 17, 19, 0.99);
|
||||
box-shadow:
|
||||
-14px 0 34px rgba(0, 0, 0, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__close {
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 3;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 11px;
|
||||
background: rgba(8, 10, 11, 0.72);
|
||||
color: rgba(243, 245, 242, 0.82);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author {
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author > span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-author em {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta {
|
||||
min-height: 0;
|
||||
gap: 5px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta h2 {
|
||||
font-size: 15px;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-meta p,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-long-copy .wb-prompt-case-meta p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-prompt,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-long-copy .wb-prompt-case-prompt,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-prompt,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-prompt {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-prompt span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-prompt p {
|
||||
font-size: 12px;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-actions,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-actions,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-actions {
|
||||
position: static;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
align-self: end;
|
||||
margin: 0;
|
||||
padding: 5px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-actions button {
|
||||
min-height: 36px;
|
||||
padding: 0 7px;
|
||||
border-radius: 11px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__panel {
|
||||
--prompt-case-modal-max-height: min(520px, calc(100svh - 56px - env(safe-area-inset-top, 0px) - 22px));
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(172px, 1.1fr);
|
||||
grid-template-rows: 1fr;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__panel,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__panel {
|
||||
grid-template-columns: minmax(0, 0.82fr) minmax(174px, 1.18fr);
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__media,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__media,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__media {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal__sidebar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-modal__sidebar,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-modal__sidebar {
|
||||
gap: 8px;
|
||||
padding: 14px 10px 12px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-actions,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-portrait-media .wb-prompt-case-actions,
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-modal.is-tall-media .wb-prompt-case-actions {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-prompt-case-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.web-shell[data-ui-theme="dark-green"][data-view="workbench"] .wb-home {
|
||||
padding: 34px 18px 44px;
|
||||
|
||||
@@ -144,6 +144,10 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -170,10 +174,15 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: calc(100% + 12px);
|
||||
right: -88px;
|
||||
z-index: 1200;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-height: min(560px, calc(100vh - 92px));
|
||||
height: auto;
|
||||
max-height: min(460px, calc(100dvh - 84px));
|
||||
border: 1px solid var(--dg-line);
|
||||
border-radius: 16px;
|
||||
background: #151719;
|
||||
@@ -231,9 +240,12 @@
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
|
||||
max-height: min(486px, calc(100vh - 158px));
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: min(386px, calc(100dvh - 158px));
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__item {
|
||||
@@ -10466,6 +10478,21 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
|
||||
right: clamp(-112px, -24vw, -92px);
|
||||
width: min(360px, calc(100vw - 20px));
|
||||
max-height: min(420px, calc(100dvh - 76px));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__panel::before {
|
||||
right: clamp(104px, 25vw, 124px);
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
|
||||
max-height: min(344px, calc(100dvh - 150px));
|
||||
}
|
||||
|
||||
.web-shell[data-ui-theme="dark-green"] :is(.creator-button, .member-button) {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
type TestFn = () => unknown | Promise<unknown>;
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
run: TestFn;
|
||||
afterEachFns: TestFn[];
|
||||
}
|
||||
|
||||
const tests: TestCase[] = [];
|
||||
const suiteStack: string[] = [];
|
||||
const afterEachStack: TestFn[][] = [[]];
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function assertThrows(
|
||||
actual: unknown,
|
||||
expectedMessage?: string,
|
||||
inverted = false,
|
||||
): void {
|
||||
if (typeof actual !== "function") {
|
||||
fail("Expected value to be a function");
|
||||
}
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
actual();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
if (inverted) {
|
||||
if (thrown)
|
||||
fail(
|
||||
`Expected function not to throw, but it threw ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!thrown) fail("Expected function to throw");
|
||||
if (expectedMessage) {
|
||||
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
||||
if (!message.includes(expectedMessage)) {
|
||||
fail(
|
||||
`Expected thrown message to include "${expectedMessage}", got "${message}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function describe(name: string, run: () => void): void {
|
||||
suiteStack.push(name);
|
||||
afterEachStack.push([]);
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
afterEachStack.pop();
|
||||
suiteStack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function it(name: string, run: TestFn): void {
|
||||
tests.push({
|
||||
name: [...suiteStack, name].join(" > "),
|
||||
run,
|
||||
afterEachFns: afterEachStack.flat(),
|
||||
});
|
||||
}
|
||||
|
||||
export function afterEach(run: TestFn): void {
|
||||
afterEachStack[afterEachStack.length - 1].push(run);
|
||||
}
|
||||
|
||||
export function expect(actual: unknown) {
|
||||
return {
|
||||
toBe(expected: unknown): void {
|
||||
if (!Object.is(actual, expected)) {
|
||||
fail(`Expected ${formatValue(actual)} to be ${formatValue(expected)}`);
|
||||
}
|
||||
},
|
||||
toEqual(expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
fail(`Expected ${actualJson} to equal ${expectedJson}`);
|
||||
}
|
||||
},
|
||||
toThrow(expectedMessage?: string): void {
|
||||
assertThrows(actual, expectedMessage);
|
||||
},
|
||||
not: {
|
||||
toThrow(): void {
|
||||
assertThrows(actual, undefined, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRegisteredTests(): Promise<{
|
||||
passed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}> {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await test.run();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
for (const hook of [...test.afterEachFns].reverse()) {
|
||||
try {
|
||||
await hook();
|
||||
} catch (error) {
|
||||
failure = failure || error;
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
failed += 1;
|
||||
console.error(`FAIL ${test.name}`);
|
||||
console.error(
|
||||
failure instanceof Error
|
||||
? ` ${failure.message}`
|
||||
: ` ${String(failure)}`,
|
||||
);
|
||||
} else {
|
||||
passed += 1;
|
||||
console.log(`PASS ${test.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = passed + failed;
|
||||
if (total === 0) {
|
||||
console.error("No unit tests were registered.");
|
||||
return { passed, failed: 1, total };
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
return { passed, failed, total };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../testHarness";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
getEnterpriseVideoCreditRate,
|
||||
normalizeEnterpriseResolution,
|
||||
} from "./enterpriseVideoPolicy";
|
||||
|
||||
describe("enterpriseVideoPolicy", () => {
|
||||
it("keeps video billing at 1 CNY to 100 credits", () => {
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "happyhorse-1.0",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(640);
|
||||
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "wan2.7-i2v",
|
||||
resolution: "720P",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(300);
|
||||
});
|
||||
|
||||
it("rounds duration up to the next second before billing", () => {
|
||||
expect(
|
||||
calculateEnterpriseVideoCredits({
|
||||
model: "vidu-q3-turbo",
|
||||
resolution: "1080P",
|
||||
durationSeconds: 5.2,
|
||||
}),
|
||||
).toBe(600);
|
||||
});
|
||||
|
||||
it("normalizes unsupported resolutions to 1080P", () => {
|
||||
expect(normalizeEnterpriseResolution("4K")).toBe("1080P");
|
||||
expect(
|
||||
getEnterpriseVideoCreditRate({
|
||||
model: "pixverse-c1",
|
||||
resolution: "4K",
|
||||
durationSeconds: 5,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ interface ErrorReport {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
let reportQueue: ErrorReport[] = [];
|
||||
const reportQueue: ErrorReport[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function getSessionId(): string | undefined {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function detectMentionTrigger(textBeforeCursor: string): MentionTriggerMa
|
||||
const query = textBeforeCursor.slice(atIdx + 1);
|
||||
|
||||
// Query must not contain spaces or punctuation that would break a mention token
|
||||
if (/[\s,。、;:!??!.,;:(){}\[\]<>""''《》【】@]/.test(query)) {
|
||||
if (/[\s,。、;:!??!.,;:(){}[\]<>""''《》【】@]/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
TEXT_INPUT_CREDITS_PER_MILLION,
|
||||
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||
estimateTextTokenCredits,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "./taskLifecycle";
|
||||
|
||||
describe("taskLifecycle", () => {
|
||||
it("keeps text token billing at 1 CNY to 100 credits", () => {
|
||||
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||
expect(
|
||||
estimateTextTokenCredits({
|
||||
promptTokens: 1_000_000,
|
||||
completionTokens: 1_000_000,
|
||||
}),
|
||||
).toBe(700);
|
||||
});
|
||||
|
||||
it("ignores negative token counts when estimating text billing", () => {
|
||||
expect(
|
||||
estimateTextTokenCredits({
|
||||
promptTokens: -100,
|
||||
completionTokens: 500_000,
|
||||
}),
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||
|
||||
expect(
|
||||
isTaskLocallyTimedOut({
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 1_000,
|
||||
now: 1_000 + policy.submitTimeoutMs,
|
||||
policy,
|
||||
progress: 0,
|
||||
}),
|
||||
).toBe("no_progress");
|
||||
});
|
||||
|
||||
it("marks running tasks locally timed out when progress stops", () => {
|
||||
const policy = getTaskTimeoutPolicy({ kind: "video", model: "wan2.7-i2v" });
|
||||
|
||||
expect(
|
||||
isTaskLocallyTimedOut({
|
||||
startedAt: 1_000,
|
||||
lastProgressAt: 2_000,
|
||||
now: 2_000 + policy.noProgressTimeoutMs,
|
||||
policy,
|
||||
progress: 40,
|
||||
}),
|
||||
).toBe("no_progress");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user