diff --git a/.codex-tmp/interactive-dialog-generator/交互式对话框生成器.html b/.codex-tmp/interactive-dialog-generator/交互式对话框生成器.html new file mode 100644 index 0000000..c7f485d --- /dev/null +++ b/.codex-tmp/interactive-dialog-generator/交互式对话框生成器.html @@ -0,0 +1,408 @@ + + + + + + 交互式对话框生成器 + + + + + + + +
+

+ 交互式对话框生成器 +

+ +
+ +
+ +
+

+ 上传背景图片 +

+
+ +

点击或拖拽图片到此处

+

支持 JPG、PNG、WEBP 格式

+ +
+
+ + +
+

+ 点击添加对话框 +

+

每点一次即在预览区新增一个对话框

+
+
+ 白色圆角对话框 +
+
+ 蓝色气泡对话框 +
+
+ 黄色提示对话框 +
+
+ 灰色简约对话框 +
+
+
+ +
+ +
+
+ + +
+

+ 预览区域 +

+
+
+
+
+ +

上传图片后开始编辑

+
+
+

+ 提示:对话框可拖动定位,输入文字后点确认即可渲染,双击已确认的框可重新编辑 +

+
+
+
+ + + + diff --git a/scripts/dynamic-analysis-v2.mjs b/scripts/dynamic-analysis-v2.mjs new file mode 100644 index 0000000..5d43054 --- /dev/null +++ b/scripts/dynamic-analysis-v2.mjs @@ -0,0 +1,301 @@ +/** + * Dynamic analysis without Playwright - uses Node.js to analyze module structure, + * dependency graph, import patterns, and potential runtime costs. + */ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative, basename } from 'path'; + +const SRC = join(import.meta.dirname, '..', 'src'); +const DIST = join(import.meta.dirname, '..', 'dist'); + +const results = []; +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') walk(full); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) { + const content = readFileSync(full, 'utf-8'); + results.push({ file: relative(join(SRC, '..'), full), content, lines: content.split('\n').length }); + } + } +} +walk(SRC); + +// ─── 1. Dependency Graph Analysis ─── +console.log('═══════════════════════════════════════════════'); +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 = []; + // Match import statements + const importRe = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g; + let m; + while ((m = importRe.exec(r.content)) !== null) { + imports.push(m[1]); + } + // Match dynamic imports + const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + while ((m = dynRe.exec(r.content)) !== null) { + imports.push(`[dynamic]${m[1]}`); + } + importMap.set(r.file, imports); +} + +// Find circular dependencies +console.log('\n--- Circular Dependency Detection ---'); +function findCircular(file, visited = new Set(), path = []) { + if (visited.has(file)) { + if (path.includes(file)) { + console.log(` [CIRCULAR] ${path.slice(path.indexOf(file)).join(' -> ')} -> ${file}`); + } + return; + } + visited.add(file); + path.push(file); + const deps = importMap.get(file) || []; + for (const dep of deps) { + if (dep.startsWith('.') || dep.startsWith('/')) { + // Resolve relative path + const dir = file.split('/').slice(0, -1).join('/'); + const resolved = dep.replace(/^\.\//, dir + '/').replace(/^\.\.\//, ''); + // Find matching file + for (const r of results) { + if (r.file.includes(resolved) || r.file.includes(basename(resolved))) { + findCircular(r.file, new Set(visited), [...path]); + } + } + } + } +} + +// Check high-fanin files (imported by many) +const fanIn = new Map(); +for (const [file, imports] of importMap) { + for (const imp of imports) { + const key = imp.replace(/\[dynamic\]/, ''); + fanIn.set(key, (fanIn.get(key) || 0) + 1); + } +} + +console.log('\n--- High Fan-In Modules (most imported) ---'); +const sortedFanIn = [...fanIn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15); +for (const [mod, count] of sortedFanIn) { + const bar = '█'.repeat(Math.min(30, count)); + console.log(` ${mod.padEnd(50)} ${String(count).padStart(3)}x ${bar}`); +} + +// Check high-fanout files (import many) +console.log('\n--- High Fan-Out Modules (import most) ---'); +const sortedFanOut = [...importMap.entries()] + .map(([f, imps]) => [f, imps.length]) + .sort((a, b) => b[1] - a[1]).slice(0, 15); +for (const [file, count] of sortedFanOut) { + const bar = '█'.repeat(Math.min(30, count)); + console.log(` ${file.padEnd(50)} ${String(count).padStart(3)} deps ${bar}`); +} + +// 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 imp of imports) { + if (imp.startsWith('[dynamic]')) dynamicImports++; + else staticImports++; + } +} +console.log(` Static imports: ${staticImports}`); +console.log(` Dynamic imports: ${dynamicImports}`); +console.log(` Lazy load ratio: ${((dynamicImports / (staticImports + dynamicImports)) * 100).toFixed(1)}%`); + +// Find files that should be lazy loaded but aren't +const largePages = results.filter(r => r.lines > 500 && r.file.includes('Page')); +for (const r of largePages) { + const isLazyImported = [...importMap.values()].some(imps => + imps.some(i => i.startsWith('[dynamic]') && i.includes(basename(r.file, '.tsx'))) + ); + if (!isLazyImported && !r.file.includes('App')) { + // Check if it's referenced in App.tsx + const appContent = results.find(x => x.file === 'src/App.tsx')?.content || ''; + if (appContent.includes(basename(r.file, '.tsx'))) { + console.log(` [INFO] ${r.file} (${r.lines} lines) - loaded via App.tsx`); + } + } +} + +// ─── 2. React Rendering Cost Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 2. REACT RENDERING COST ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +// Count useState/useReducer per component (state update triggers re-render) +console.log('\n--- State Hook Density ---'); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const stateHooks = (r.content.match(/useState\s*[<(]/g) || []).length; + const reducers = (r.content.match(/useReducer\s*[<(]/g) || []).length; + const effects = (r.content.match(/useEffect\s*\(/g) || []).length; + const memos = (r.content.match(/useMemo\s*\(/g) || []).length; + const callbacks = (r.content.match(/useCallback\s*[<(]/g) || []).length; + const refs = (r.content.match(/useRef\s*[<(]/g) || []).length; + + const totalHooks = stateHooks + reducers + effects + memos + callbacks + refs; + if (totalHooks > 15) { + const risk = totalHooks > 30 ? '🔴 HIGH' : totalHooks > 20 ? '🟡 MEDIUM' : '🟢 LOW'; + console.log(` ${risk} ${r.file}`); + console.log(` useState:${stateHooks} useReducer:${reducers} useEffect:${effects} useMemo:${memos} useCallback:${callbacks} useRef:${refs} (total:${totalHooks})`); + + // Check if there are many state updates that could be batched + const setters = r.content.match(/set\w+\(/g) || []; + if (setters.length > 20) { + console.log(` ⚠️ ${setters.length} state setter calls — potential for excessive re-renders`); + } + + // Check for missing useMemo on expensive computations + const expensiveInRender = (r.content.match(/\.map\(|\.filter\(|\.reduce\(|\.sort\(/g) || []).length; + if (expensiveInRender > 5 && memos === 0) { + console.log(` ⚠️ ${expensiveInRender} array operations in render body with 0 useMemo`); + } + } +} + +// ─── 3. useEffect Dependency Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 3. useEffect DEPENDENCY ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + // Find useEffect with no dependency array (runs every render) + const noDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*\)/g) || []).length; + // Find useEffect with empty deps + const emptyDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[\s*\]\s*\)/g) || []).length; + + if (noDeps > 0) { + console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`); + } +} + +// ─── 4. Zustand Store Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 4. ZUSTAND STORE ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +const storeFiles = results.filter(r => r.file.includes('store') || r.file.includes('Store')); +for (const r of storeFiles) { + const stateFields = (r.content.match(/^\s+\w+:/gm) || []).length; + const actions = (r.content.match(/^\s+\w+\s*:\s*(\(|function|\w+\s*=>)/gm) || []).length; + const subscribers = (r.content.match(/subscribe\s*\(/g) || []).length; + + console.log(`\n ${r.file}`); + console.log(` State fields: ~${stateFields}`); + console.log(` Actions: ~${actions}`); + console.log(` Subscribers: ${subscribers}`); + + // Check for selector usage patterns + if (r.content.includes('set(') && !r.content.includes('useShallow')) { + console.log(` ⚠️ Uses set() without useShallow — may cause unnecessary re-renders`); + } +} + +// ─── 5. Bundle Composition Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 5. BUNDLE COMPOSITION ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +if (readdirSync(DIST).includes('assets')) { + const assets = readdirSync(join(DIST, 'assets')); + const jsFiles = assets.filter(f => f.endsWith('.js') && !f.endsWith('.br')); + const cssFiles = assets.filter(f => f.endsWith('.css') && !f.endsWith('.br')); + + let totalJs = 0, totalCss = 0; + const chunks = []; + for (const f of jsFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalJs += size; + chunks.push({ name: f, sizeKB: size / 1024, type: 'js' }); + } + for (const f of cssFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalCss += size; + chunks.push({ name: f, sizeKB: size / 1024, type: 'css' }); + } + + chunks.sort((a, b) => b.sizeKB - a.sizeKB); + + console.log(`\n Total JS: ${(totalJs / 1024).toFixed(1)} KB (${(totalJs / 1024 / 1024).toFixed(2)} MB)`); + console.log(` Total CSS: ${(totalCss / 1024).toFixed(1)} KB (${(totalCss / 1024 / 1024).toFixed(2)} MB)`); + console.log(` Total: ${((totalJs + totalCss) / 1024).toFixed(1)} KB`); + + // CSS is suspiciously large + const cssRatio = totalCss / totalJs; + if (cssRatio > 0.5) { + console.log(`\n ⚠️ CSS/JS ratio: ${(cssRatio * 100).toFixed(0)}% — CSS bundle may contain unused styles`); + console.log(` Consider: PurgeCSS, CSS Modules, or extracting to separate loads`); + } + + // Check initial load budget + const initialChunks = chunks.filter(c => + c.name.includes('index') || c.name.includes('vendor-react') || c.name.includes('vendor-antd') + ); + const initialLoad = initialChunks.reduce((s, c) => s + c.sizeKB, 0); + console.log(`\n Critical path (initial load):`); + for (const c of initialChunks) { + console.log(` ${c.name.padEnd(45)} ${c.sizeKB.toFixed(1).padStart(8)} KB`); + } + console.log(` ${'TOTAL'.padEnd(45)} ${initialLoad.toFixed(1).padStart(8)} KB`); + + if (initialLoad > 300) { + console.log(`\n ⚠️ Initial load > 300KB — consider code splitting or tree-shaking`); + } +} + +// ─── 6. API Client Efficiency ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 6. API CLIENT EFFICIENCY'); +console.log('═══════════════════════════════════════════════'); + +const apiFiles = results.filter(r => r.file.includes('src/api/')); +for (const r of apiFiles) { + const fetchCalls = (r.content.match(/fetch\s*\(/g) || []).length; + const retries = (r.content.match(/retry|Retry|RETRY/g) || []).length; + const abortSignals = (r.content.match(/AbortSignal|AbortController/g) || []).length; + const timeouts = (r.content.match(/timeout|Timeout|setTimeout/g) || []).length; + + if (fetchCalls > 0) { + console.log(`\n ${r.file}`); + console.log(` fetch calls: ${fetchCalls}`); + console.log(` retry logic: ${retries > 0 ? '✅' : '❌ none'}`); + console.log(` abort signals: ${abortSignals > 0 ? '✅' : '❌ none'}`); + console.log(` timeouts: ${timeouts}`); + } +} + +// ─── 7. TypeScript Compilation Metrics ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 7. TYPE COMPLEXITY'); +console.log('═══════════════════════════════════════════════'); + +let totalTypes = 0, totalInterfaces = 0, totalEnums = 0, totalGenerics = 0; +for (const r of results) { + totalTypes += (r.content.match(/^export\s+type\s+/gm) || []).length; + totalTypes += (r.content.match(/^type\s+/gm) || []).length; + totalInterfaces += (r.content.match(/^export\s+interface\s+/gm) || []).length; + totalInterfaces += (r.content.match(/^interface\s+/gm) || []).length; + totalEnums += (r.content.match(/enum\s+\w+/g) || []).length; + totalGenerics += (r.content.match(/<\w+(?:\s*,\s*\w+)*>/g) || []).length; +} + +console.log(` Type aliases: ${totalTypes}`); +console.log(` Interfaces: ${totalInterfaces}`); +console.log(` Enums: ${totalEnums}`); +console.log(` Generic usages: ${totalGenerics}`); +console.log(` Total TS files: ${results.length}`); + +// ─── Final Summary ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' ANALYSIS COMPLETE'); +console.log('═══════════════════════════════════════════════'); diff --git a/scripts/dynamic-analysis.mjs b/scripts/dynamic-analysis.mjs new file mode 100644 index 0000000..ae8106e --- /dev/null +++ b/scripts/dynamic-analysis.mjs @@ -0,0 +1,305 @@ +/** + * Dynamic performance analysis using Playwright. + * Measures: page load, bundle sizes, memory, rendering, network. + */ +import { chromium } from 'playwright'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const DIST = join(import.meta.dirname, '..', 'dist'); +const PORT = 4174; + +// ─── Bundle analysis from dist ─── +function analyzeBundles() { + const assets = readdirSync(join(DIST, 'assets')); + const jsFiles = assets.filter(f => f.endsWith('.js')); + const cssFiles = assets.filter(f => f.endsWith('.css')); + const brFiles = assets.filter(f => f.endsWith('.js.br')); + + let totalJsSize = 0, totalCssSize = 0, totalBrSize = 0; + const bundles = []; + + for (const f of jsFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalJsSize += size; + bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'js' }); + } + for (const f of cssFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalCssSize += size; + bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'css' }); + } + for (const f of brFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalBrSize += size; + } + + bundles.sort((a, b) => parseFloat(b.sizeKB) - parseFloat(a.sizeKB)); + + console.log('\n═══════════════════════════════════════════════'); + console.log(' BUNDLE SIZE ANALYSIS'); + console.log('═══════════════════════════════════════════════'); + console.log(`\nTotal JS (raw): ${(totalJsSize / 1024).toFixed(1)} KB`); + console.log(`Total CSS (raw): ${(totalCssSize / 1024).toFixed(1)} KB`); + console.log(`Total JS (brotli): ${(totalBrSize / 1024).toFixed(1)} KB`); + console.log(`\nTop 15 bundles by raw size:`); + for (const b of bundles.slice(0, 15)) { + const bar = '█'.repeat(Math.min(40, Math.round(parseFloat(b.sizeKB) / 5))); + console.log(` ${b.name.padEnd(45)} ${String(b.sizeKB).padStart(8)} KB ${bar}`); + } + + // Identify oversized chunks + console.log('\n⚠️ Oversized chunks (>50KB raw):'); + for (const b of bundles.filter(b => parseFloat(b.sizeKB) > 50)) { + console.log(` [WARN] ${b.name} = ${b.sizeKB} KB`); + } + + return { totalJsSize, totalCssSize, totalBrSize, bundles }; +} + +// ─── Runtime performance with Playwright ─── +async function runtimeAnalysis() { + console.log('\n═══════════════════════════════════════════════'); + console.log(' RUNTIME PERFORMANCE ANALYSIS'); + console.log('═══════════════════════════════════════════════'); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } }); + const page = await context.newPage(); + + // Collect performance metrics + const perfEntries = []; + page.on('console', msg => { + if (msg.type() === 'info' && msg.text().startsWith('[PERF]')) { + perfEntries.push(msg.text()); + } + }); + + // Track network requests + const networkRequests = []; + page.on('request', req => { + networkRequests.push({ + url: req.url(), + method: req.method(), + startTime: Date.now() + }); + }); + + const networkResponses = []; + page.on('response', res => { + networkResponses.push({ + url: res.url(), + status: res.status(), + size: res.headers()['content-length'] || '0', + endTime: Date.now() + }); + }); + + // Measure page load + console.log('\n--- Page Load Performance ---'); + const navStart = Date.now(); + + try { + const response = await page.goto(`http://127.0.0.1:${PORT}/`, { + waitUntil: 'networkidle', + timeout: 30000 + }); + const loadTime = Date.now() - navStart; + console.log(` Initial page load: ${loadTime}ms`); + console.log(` HTTP status: ${response.status()}`); + + // Get navigation timing from the browser + const navTiming = await page.evaluate(() => { + const perf = performance.getEntriesByType('navigation')[0]; + if (!perf) return null; + return { + dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart), + tcp: Math.round(perf.connectEnd - perf.connectStart), + ttfb: Math.round(perf.responseStart - perf.requestStart), + download: Math.round(perf.responseEnd - perf.responseStart), + domInteractive: Math.round(perf.domInteractive), + domComplete: Math.round(perf.domComplete), + loadEvent: Math.round(perf.loadEventEnd), + transferSize: perf.transferSize, + }; + }); + + if (navTiming) { + console.log(`\n Navigation Timing:`); + console.log(` DNS lookup: ${navTiming.dns}ms`); + console.log(` TCP connect: ${navTiming.tcp}ms`); + console.log(` TTFB: ${navTiming.ttfb}ms`); + console.log(` Download: ${navTiming.download}ms`); + console.log(` DOM interactive: ${navTiming.domInteractive}ms`); + console.log(` DOM complete: ${navTiming.domComplete}ms`); + console.log(` Load event end: ${navTiming.loadEvent}ms`); + console.log(` Transfer size: ${(navTiming.transferSize / 1024).toFixed(1)} KB`); + } + + // Resource timing + const resources = await page.evaluate(() => { + return performance.getEntriesByType('resource').map(r => ({ + name: r.name.split('/').pop(), + type: r.initiatorType, + duration: Math.round(r.duration), + size: r.transferSize, + })); + }); + + console.log(`\n--- Resource Loading ---`); + console.log(` Total resources: ${resources.length}`); + const totalTransfer = resources.reduce((s, r) => s + r.size, 0); + console.log(` Total transfer: ${(totalTransfer / 1024).toFixed(1)} KB`); + + const slowResources = resources.filter(r => r.duration > 100).sort((a, b) => b.duration - a.duration); + if (slowResources.length > 0) { + console.log(`\n Slow resources (>100ms):`); + for (const r of slowResources.slice(0, 10)) { + console.log(` [SLOW] ${r.name.padEnd(40)} ${r.duration}ms (${(r.size/1024).toFixed(1)}KB)`); + } + } + + // Memory analysis + console.log(`\n--- Memory Analysis ---`); + const memory = await page.evaluate(() => { + if (performance.memory) { + return { + usedJSHeap: performance.memory.usedJSHeapSize, + totalJSHeap: performance.memory.totalJSHeapSize, + heapLimit: performance.memory.jsHeapSizeLimit, + }; + } + return null; + }); + + if (memory) { + console.log(` Used JS heap: ${(memory.usedJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Total JS heap: ${(memory.totalJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Heap limit: ${(memory.heapLimit / 1024 / 1024).toFixed(1)} MB`); + console.log(` Heap utilization: ${((memory.usedJSHeap / memory.heapLimit) * 100).toFixed(1)}%`); + } else { + console.log(' Memory API not available (Chromium flag needed: --enable-precise-memory-info)'); + } + + // DOM complexity + console.log(`\n--- DOM Complexity ---`); + const domStats = await page.evaluate(() => { + const allElements = document.querySelectorAll('*'); + const tagCounts = {}; + let maxDepth = 0; + let totalNodes = allElements.length; + + allElements.forEach(el => { + const tag = el.tagName.toLowerCase(); + tagCounts[tag] = (tagCounts[tag] || 0) + 1; + let depth = 0, parent = el.parentElement; + while (parent) { depth++; parent = parent.parentElement; } + if (depth > maxDepth) maxDepth = depth; + }); + + return { totalNodes, maxDepth, tagCounts }; + }); + + console.log(` Total DOM nodes: ${domStats.totalNodes}`); + console.log(` Max DOM depth: ${domStats.maxDepth}`); + console.log(` Top 10 tags:`); + const sortedTags = Object.entries(domStats.tagCounts).sort((a, b) => b[1] - a[1]); + for (const [tag, count] of sortedTags.slice(0, 10)) { + console.log(` <${tag}>: ${count}`); + } + + // DOM warnings + if (domStats.totalNodes > 1500) { + console.log(` ⚠️ DOM nodes > 1500 — may cause sluggish rendering`); + } + if (domStats.maxDepth > 15) { + console.log(` ⚠️ DOM depth > 15 — may slow style/layout calculations`); + } + + // React-specific analysis: check for unnecessary re-renders + console.log(`\n--- Render Performance ---`); + const renderMetrics = await page.evaluate(() => { + // Check if React DevTools hook exists + const hasReact = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + return { + hasReact, + eventListeners: typeof getEventListeners !== 'undefined' ? 'available' : 'not-in-page-context', + }; + }); + console.log(` React DevTools: ${renderMetrics.hasReact ? 'detected' : 'not detected'}`); + + // Measure interaction performance - simulate scroll + console.log(`\n--- Interaction Performance ---`); + const scrollStart = Date.now(); + await page.evaluate(() => { + const container = document.querySelector('.app-shell-content') || document.documentElement; + for (let i = 0; i < 10; i++) { + container.scrollTop = i * 100; + } + container.scrollTop = 0; + }); + const scrollTime = Date.now() - scrollStart; + console.log(` 10x scroll ops: ${scrollTime}ms`); + + // Navigate to different routes to test lazy loading + const routes = ['#/', '#/canvas', '#/workbench', '#/ecommerce', '#/image-workbench', '#/home']; + console.log(`\n--- Route Navigation (Lazy Loading) ---`); + for (const route of routes) { + const routeStart = Date.now(); + await page.goto(`http://127.0.0.1:${PORT}/${route}`, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const routeTime = Date.now() - routeStart; + console.log(` ${route.padEnd(30)} ${routeTime}ms`); + } + + // Memory after navigation + const memoryAfter = await page.evaluate(() => { + if (performance.memory) { + return { + usedJSHeap: performance.memory.usedJSHeapSize, + totalJSHeap: performance.memory.totalJSHeapSize, + }; + } + return null; + }); + + if (memoryAfter) { + console.log(`\n--- Memory After Route Navigation ---`); + console.log(` Used JS heap: ${(memoryAfter.usedJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Total JS heap: ${(memoryAfter.totalJSHeap / 1024 / 1024).toFixed(1)} MB`); + if (memory) { + const delta = memoryAfter.usedJSHeap - memory.usedJSHeap; + console.log(` Heap delta: ${(delta > 0 ? '+' : '')}${(delta / 1024 / 1024).toFixed(1)} MB`); + } + } + + // Network summary + console.log(`\n--- Network Summary ---`); + console.log(` Total requests: ${networkResponses.length}`); + const totalNetworkSize = networkResponses.reduce((s, r) => s + parseInt(r.size || '0'), 0); + console.log(` Total response: ${(totalNetworkSize / 1024).toFixed(1)} KB`); + const failedRequests = networkResponses.filter(r => r.status >= 400); + if (failedRequests.length > 0) { + console.log(` Failed requests: ${failedRequests.length}`); + for (const r of failedRequests) { + console.log(` [${r.status}] ${r.url}`); + } + } + + } catch (err) { + console.log(`\n ❌ Error during runtime analysis: ${err.message}`); + } finally { + await browser.close(); + } +} + +// ─── Main ─── +console.log('╔═══════════════════════════════════════════════╗'); +console.log('║ OmniAI Web Preview - Performance Analysis ║'); +console.log('╚═══════════════════════════════════════════════╝'); + +const bundleResult = analyzeBundles(); +await runtimeAnalysis(); + +console.log('\n═══════════════════════════════════════════════'); +console.log(' ANALYSIS COMPLETE'); +console.log('═══════════════════════════════════════════════'); diff --git a/scripts/static-analysis.mjs b/scripts/static-analysis.mjs new file mode 100644 index 0000000..afa32f9 --- /dev/null +++ b/scripts/static-analysis.mjs @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const SRC = join(import.meta.dirname, '..', 'src'); +const results = []; + +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') walk(full); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) { + const content = readFileSync(full, 'utf-8'); + const lines = content.split('\n').length; + results.push({ file: relative(join(SRC, '..'), full), lines, content }); + } + } +} +walk(SRC); +results.sort((a, b) => b.lines - a.lines); + +console.log('=== TOP 30 FILES BY LINE COUNT ==='); +for (const r of results.slice(0, 30)) { + console.log(`${String(r.lines).padStart(5)} ${r.file}`); +} + +// Detect nested loops (3+ levels) +console.log('\n=== NESTED LOOP DETECTION (3+ levels) ==='); +const loopPatterns = [ + /for\s*\(/g, /while\s*\(/g, /\.forEach\s*\(/g, /\.map\s*\(/g, + /\.filter\s*\(/g, /\.reduce\s*\(/g, /\.some\s*\(/g, /\.every\s*\(/g, + /\.flatMap\s*\(/g, /\.find\s*\(/g +]; + +for (const r of results) { + const lines = r.content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let loopCount = 0; + for (const p of loopPatterns) { + p.lastIndex = 0; + if (p.test(line)) loopCount++; + } + // Check surrounding context for nesting + if (loopCount > 0 || /for\s*\(/.test(line) || /\.map\(/.test(line) || /\.forEach\(/.test(line) || /\.filter\(/.test(line) || /\.reduce\(/.test(line)) { + // Count loop keywords on this single line + let singleLineLoops = 0; + for (const p of loopPatterns) { + p.lastIndex = 0; + const matches = line.match(new RegExp(p.source, 'g')); + if (matches) singleLineLoops += matches.length; + } + if (singleLineLoops >= 2) { + console.log(` [NESTED] ${r.file}:${i + 1} (${singleLineLoops} loops on one line)`); + console.log(` ${line.trim().substring(0, 120)}`); + } + } + } +} + +// Detect missing cleanup in useEffect +console.log('\n=== MEMORY LEAK RISK: useEffect without cleanup ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx') && !r.file.endsWith('.ts')) continue; + const content = r.content; + // Find useEffect blocks that contain setInterval/setTimeout/addEventListener but no return + const useEffectRegex = /useEffect\s*\(\s*\(\)\s*=>\s*\{([\s\S]*?)\}\s*,/g; + let match; + while ((match = useEffectRegex.exec(content)) !== null) { + const body = match[1]; + const hasTimer = /setInterval|setTimeout/.test(body); + const hasListener = /addEventListener/.test(body); + const hasSubscribe = /\.subscribe\(/.test(body); + const hasCleanup = /return\s*\(\)\s*=>|return\s*function|return\s*\(\{/.test(body); + + if ((hasTimer || hasListener || hasSubscribe) && !hasCleanup) { + const lineNum = content.substring(0, match.index).split('\n').length; + console.log(` [RISK] ${r.file}:${lineNum}`); + if (hasTimer) console.log(` - Has setInterval/setTimeout without cleanup`); + if (hasListener) console.log(` - Has addEventListener without cleanup`); + if (hasSubscribe) console.log(` - Has subscribe without cleanup`); + console.log(` ${body.trim().substring(0, 200)}`); + console.log(''); + } + } +} + +// Detect objects/arrays/functions created in render body (not memoized) +console.log('\n=== REDUNDANT COMPUTATION: Non-memoized values in components ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const lines = r.content.split('\n'); + // Look for const x = [...], const x = {...}, const x = (...) => patterns outside useMemo + let inUseMemo = 0; + for (let i = 0; i < lines.length; i++) { + if (/useMemo\s*\(/.test(lines[i])) inUseMemo++; + if (inUseMemo > 0 && /\)/.test(lines[i])) { + // Rough heuristic - not perfect + } + if (inUseMemo === 0) { + // Expensive operations in render + if (/\.map\s*\(.*\.map\s*\(/.test(lines[i])) { + console.log(` [PERF] ${r.file}:${i + 1} - Chained .map calls in render`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + if (/\.filter\s*\(.*\.map\s*\(/.test(lines[i]) || /\.map\s*\(.*\.filter\s*\(/.test(lines[i])) { + console.log(` [PERF] ${r.file}:${i + 1} - filter+map chain in render`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + } + } +} + +// Detect deeply nested conditionals (4+ levels) +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; + const indent = line.match(/^(\s*)/)[1].length; + // Only flag if inside if/else/switch/ternary + if (indent >= 16 && /if\s*\(|else|switch\s*\(|case\s+/.test(line.trim())) { + console.log(` [DEEP] ${r.file}:${i + 1} (indent=${indent})`); + console.log(` ${line.trim().substring(0, 120)}`); + } + } +} + +// Detect inline style objects in JSX (recreated every render) +console.log('\n=== REDUNDANT: Inline style objects in JSX ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const lines = r.content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (/style\s*=\s*\{\s*\{/.test(lines[i]) && !/useMemo/.test(lines[i])) { + console.log(` [INLINE] ${r.file}:${i + 1}`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + } +} + +// Total stats +console.log('\n=== SUMMARY ==='); +console.log(`Total files: ${results.length}`); +console.log(`Total lines: ${results.reduce((s, r) => s + r.lines, 0)}`); +console.log(`Files > 500 lines: ${results.filter(r => r.lines > 500).length}`); +console.log(`Files > 1000 lines: ${results.filter(r => r.lines > 1000).length}`); diff --git a/src/App.tsx b/src/App.tsx index 8a2abd3..978aa4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,11 +308,13 @@ function App() { // Task store const { tasks, + setTasks, appendTask, mergeServerTasks, clearTasks, } = useTaskStore(useShallow((s) => ({ tasks: s.tasks, + setTasks: s.setTasks, appendTask: s.appendTask, mergeServerTasks: s.mergeServerTasks, clearTasks: s.clearTasks, @@ -1122,6 +1124,8 @@ function App() { onOpenWorkbench={() => handleSetView("workbench")} onOpenCommunity={() => handleSetView("community")} onDeleteProject={handleDeleteProject} + onOpenProject={handleOpenProject} + onRemoveWork={(task) => setTasks(tasks.filter((item) => item.id !== task.id))} /> ); case "community": diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 2efa463..662f84d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -53,9 +53,9 @@ const PRIMARY_NAV_ORDER: WebViewKey[] = [ "canvas", "scriptTokens", "tokenUsage", - "community", - "assets", "more", + "assets", + "community", ]; function formatBalance(cents: number): string { diff --git a/src/features/agent/AgentPage.tsx b/src/features/agent/AgentPage.tsx index ad0d46d..21ce50d 100644 --- a/src/features/agent/AgentPage.tsx +++ b/src/features/agent/AgentPage.tsx @@ -13,7 +13,7 @@ import { SendOutlined, ThunderboltOutlined, } from "@ant-design/icons"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import "../../styles/pages/agent.css"; import WorkspacePageShell from "../../components/WorkspacePageShell"; import type { WebGenerationPreviewTask } from "../../types"; @@ -73,6 +73,24 @@ const agentModes = [ }, ]; +const agentModelOptions = [ + { id: "gemini-3.1-pro", label: "Gemini 3.1 Pro" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gpt-4o", label: "GPT-4o" }, +]; + +const thinkingSpeedOptions = [ + { id: "fast", label: "快速" }, + { id: "balanced", label: "均衡" }, + { id: "precise", label: "精细" }, +]; + +const thinkingDepthOptions = [ + { id: "concise", label: "简洁" }, + { id: "standard", label: "标准" }, + { id: "deep", label: "深度" }, +]; + const quickStarts = ["「新品发布」全链路运营", "「销售日报」自动分析", "「竞品监控」每周报告"]; function getTaskSourceLabel(task: WebGenerationPreviewTask): string | null { @@ -94,6 +112,21 @@ function AgentPage({ const [prompt, setPrompt] = useState("让 Omni Agent 帮我规划「新品发布会全流程」"); const [isRunning, setIsRunning] = useState(false); const [notice, setNotice] = useState("选择一个 Agent 模式,输入目标后即可开始。"); + const [agentModel, setAgentModel] = useState(agentModelOptions[0].id); + const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[1].id); + const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[1].id); + const [activeDropdown, setActiveDropdown] = useState(null); + const controlsRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) { + setActiveDropdown(null); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); const selectedMode = agentModes.find((item) => item.id === activeMode) ?? agentModes[0]; const recentTasks = tasks.slice(0, 3); @@ -204,15 +237,85 @@ function AgentPage({ />
-
+
- +
+ + {activeDropdown === "model" && ( +
+ {agentModelOptions.map((m) => ( + + ))} +
+ )} +
+
+ + {activeDropdown === "speed" && ( +
+ {thinkingSpeedOptions.map((s) => ( + + ))} +
+ )} +
+
+ + {activeDropdown === "depth" && ( +
+ {thinkingDepthOptions.map((d) => ( + + ))} +
+ )} +
diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index fb7bc8b..50ee0b2 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -398,6 +398,8 @@ function CanvasPage({ const canvasRef = useRef(null); const videoGenerationInFlightRef = useRef(new Set()); const canvasReferenceUploadPromisesRef = useRef(new Map>()); + const canvasDragCounterRef = useRef(0); + const [isCanvasDragging, setIsCanvasDragging] = useState(false); const suppressNextPaneClickRef = useRef(false); const canvasAutoSaveTimerRef = useRef(null); const canvasAutoSaveIdleHandleRef = useRef(null); @@ -1335,7 +1337,7 @@ function CanvasPage({ model: defaultVideoModel, aspectRatio: "16:9", resolution: getDefaultVideoQuality(defaultVideoModel), - duration: "4", + duration: "5", videoMode: "text2video", sourceTextNodeId: source.id, position: { @@ -1359,7 +1361,7 @@ function CanvasPage({ model: defaultVideoModel, aspectRatio: "16:9", resolution: getDefaultVideoQuality(defaultVideoModel), - duration: "4", + duration: "5", videoMode: "text2video", sourceTextNodeId: "", position, @@ -1415,7 +1417,7 @@ function CanvasPage({ imageUrl = "", fileName = "本地图片", position = { x: 0, y: 0 }, - options?: { title?: string; sourceImageNodeId?: string } + options?: { title?: string; sourceImageNodeId?: string; sourceTextNodeId?: string } ) => { const nodeNumber = imageNodeIdRef.current; imageNodeIdRef.current += 1; @@ -1429,6 +1431,7 @@ function CanvasPage({ imageSize: getDefaultImageQuality(fallbackVisibleImageModel), fileName, sourceImageNodeId: options?.sourceImageNodeId, + sourceTextNodeId: options?.sourceTextNodeId, position, size: createCanvasNodeSize("image"), }; @@ -2034,6 +2037,120 @@ function CanvasPage({ setNodeMenu(null); }; + // ── Canvas drag-and-drop file upload ────────────────────────────── + const handleCanvasDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + canvasDragCounterRef.current += 1; + if (canvasDragCounterRef.current === 1) { + setIsCanvasDragging(true); + } + }, []); + + const handleCanvasDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + canvasDragCounterRef.current -= 1; + if (canvasDragCounterRef.current <= 0) { + canvasDragCounterRef.current = 0; + setIsCanvasDragging(false); + } + }, []); + + const handleCanvasDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleCanvasDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + canvasDragCounterRef.current = 0; + setIsCanvasDragging(false); + const files = Array.from(e.dataTransfer.files).filter( + (f) => f.type.startsWith("image/") + ); + if (files.length === 0) return; + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const dropPosition = { + x: (e.clientX - rect.left - canvasViewport.x) / canvasViewport.zoom, + y: (e.clientY - rect.top - canvasViewport.y) / canvasViewport.zoom, + }; + + let offsetX = 0; + let offsetY = 0; + for (const file of files) { + const imageUrl = URL.createObjectURL(file); + addImageNode(imageUrl, file.name, { + x: dropPosition.x + offsetX, + y: dropPosition.y + offsetY, + }); + offsetX += 60; + offsetY += 60; + } + setContextMenu(null); + setNodeMenu(null); + }, + [canvasViewport.x, canvasViewport.y, canvasViewport.zoom, addImageNode], + ); + + // ── Text composer drag-and-drop ────────────────────────────────── + const [textComposerDragNodeId, setTextComposerDragNodeId] = useState(null); + const textComposerDragCounterRef = useRef(0); + + const handleTextComposerDragEnter = useCallback((_e: React.DragEvent, nodeId: string) => { + _e.preventDefault(); + _e.stopPropagation(); + textComposerDragCounterRef.current += 1; + if (textComposerDragCounterRef.current === 1) { + setTextComposerDragNodeId(nodeId); + } + }, []); + + const handleTextComposerDragLeave = useCallback((_e: React.DragEvent) => { + _e.preventDefault(); + _e.stopPropagation(); + textComposerDragCounterRef.current -= 1; + if (textComposerDragCounterRef.current <= 0) { + textComposerDragCounterRef.current = 0; + setTextComposerDragNodeId(null); + } + }, []); + + const handleTextComposerDragOver = useCallback((_e: React.DragEvent) => { + _e.preventDefault(); + _e.stopPropagation(); + }, []); + + const handleTextComposerDrop = useCallback( + (e: React.DragEvent, sourceNode: CanvasTextNode) => { + e.preventDefault(); + e.stopPropagation(); + textComposerDragCounterRef.current = 0; + setTextComposerDragNodeId(null); + const files = Array.from(e.dataTransfer.files).filter( + (f) => f.type.startsWith("image/") + ); + if (files.length === 0) return; + + let offsetX = 0; + let offsetY = 0; + for (const file of files) { + const imageUrl = URL.createObjectURL(file); + addImageNode(imageUrl, file.name, { + x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX, + y: sourceNode.position.y + offsetY, + }, { sourceTextNodeId: sourceNode.id }); + offsetX += 60; + offsetY += 60; + } + }, + [addImageNode], + ); + const activeTextNode = textNodeMenu ? textNodes.find((node) => node.id === textNodeMenu.nodeId) ?? null : null; @@ -3632,7 +3749,7 @@ function CanvasPage({
event.preventDefault() : handleCanvasContextMenu} @@ -3640,6 +3757,10 @@ function CanvasPage({ onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} + onDragEnter={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragEnter} + onDragOver={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragOver} + onDragLeave={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragLeave} + onDrop={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDrop} style={{ "--canvas-bg-size": `${34 * canvasViewport.zoom}px`, "--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`, diff --git a/src/features/canvas/canvasConstants.ts b/src/features/canvas/canvasConstants.ts index 0c46451..b0c1e01 100644 --- a/src/features/canvas/canvasConstants.ts +++ b/src/features/canvas/canvasConstants.ts @@ -140,7 +140,6 @@ export const videoRatioOptions: CanvasOption[] = [ ]; export const videoDurationOptions: CanvasOption[] = [ - { value: "4", label: "4s" }, { value: "5", label: "5s" }, { value: "6", label: "6s" }, { value: "7", label: "7s" }, diff --git a/src/features/character-mix/CharacterMixPage.tsx b/src/features/character-mix/CharacterMixPage.tsx index faa30cc..a2f01be 100644 --- a/src/features/character-mix/CharacterMixPage.tsx +++ b/src/features/character-mix/CharacterMixPage.tsx @@ -16,7 +16,7 @@ import { ThunderboltOutlined, VideoCameraOutlined, } 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 StudioToolLayout from "../../components/StudioToolLayout"; @@ -60,6 +60,7 @@ function CharacterMixPage({ const [resultUrl, setResultUrl] = useState(null); const abortRef = useRef(false); const taskIdRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); useEffect(() => { return () => { @@ -235,6 +236,32 @@ function CharacterMixPage({ } }; + const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); }; + const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); }; + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer?.files?.[0]; + if (!file) return; + if (file.type.startsWith("image/")) { + if (characterPreview) URL.revokeObjectURL(characterPreview); + setCharacterFile(file.name); + setCharacterPreview(URL.createObjectURL(file)); + const reader = new FileReader(); + reader.onload = () => { if (typeof reader.result === "string") setCharacterDataUrl(reader.result); }; + reader.readAsDataURL(file); + setNotice(`已选择人物图 ${file.name}`); + } else if (file.type.startsWith("video/")) { + if (videoPreview) URL.revokeObjectURL(videoPreview); + setVideoFile(file.name); + setVideoPreview(URL.createObjectURL(file)); + const reader2 = new FileReader(); + reader2.onload = () => { if (typeof reader2.result === "string") setVideoDataUrl(reader2.result); }; + reader2.readAsDataURL(file); + setNotice(`已选择参考视频 ${file.name}`); + } + }; + return (
@@ -294,7 +321,17 @@ function CharacterMixPage({ +
+ {isDragging ? ( +
+ 释放文件以上传 +
+ ) : null}
人物图 @@ -372,7 +409,7 @@ function CharacterMixPage({
- +
} canvas={ isCreating ? ( diff --git a/src/features/dialog-generator/DialogGeneratorPage.tsx b/src/features/dialog-generator/DialogGeneratorPage.tsx index e67278c..2261ae7 100644 --- a/src/features/dialog-generator/DialogGeneratorPage.tsx +++ b/src/features/dialog-generator/DialogGeneratorPage.tsx @@ -1,7 +1,9 @@ -import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react"; +import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons"; import "../../styles/pages/dialog-generator.css"; type DialogStyle = "style1" | "style2" | "style3" | "style4"; +type GenerationMode = "dialog" | "video"; interface DialogItem { id: number; @@ -40,16 +42,68 @@ const textColorOptions = [ { value: "#00ff88", label: "绿色" }, ]; +const dialogModelOptions = [ + { id: "gemini", label: "Gemini" }, + { id: "wanxian", label: "万相" }, + { id: "deepseek", label: "DeepSeek" }, +]; + +const thinkingSpeedOptions = [ + { id: "default", label: "默认" }, + { id: "high", label: "高" }, + { id: "ultra", label: "急速" }, +]; + +const thinkingDepthOptions = [ + { id: "default", label: "默认" }, + { id: "strong", label: "强" }, + { id: "extreme", label: "极限" }, +]; + +const videoDurationOptions = [ + { value: "5", label: "5s" }, + { value: "6", label: "6s" }, + { value: "7", label: "7s" }, + { value: "8", label: "8s" }, + { value: "9", label: "9s" }, + { value: "10", label: "10s" }, + { value: "11", label: "11s" }, + { value: "12", label: "12s" }, + { value: "13", label: "13s" }, + { value: "14", label: "14s" }, + { value: "15", label: "15s" }, +]; + function DialogGeneratorPage() { const fileInputRef = useRef(null); const previewRef = useRef(null); const dragRef = useRef(null); const nextIdRef = useRef(0); + const controlsRef = useRef(null); const [backgroundUrl, setBackgroundUrl] = useState(""); const [dialogs, setDialogs] = useState([]); const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value); const [activeDragId, setActiveDragId] = useState(null); + // ── Generation state ── + const [generationMode, setGenerationMode] = useState("dialog"); + const [dialogModel, setDialogModel] = useState(dialogModelOptions[0].id); + const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[0].id); + const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[0].id); + const [videoDuration, setVideoDuration] = useState(videoDurationOptions[0].value); + const [activeDropdown, setActiveDropdown] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) { + setActiveDropdown(null); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + const handleFile = useCallback((file?: File | null) => { if (!file || !file.type.startsWith("image/")) return; const reader = new FileReader(); @@ -195,6 +249,141 @@ function DialogGeneratorPage() {
+
+

生成设置

+
+ + +
+ +
+ {generationMode === "dialog" ? ( + <> +
+ + {activeDropdown === "model" && ( +
+ {dialogModelOptions.map((m) => ( + + ))} +
+ )} +
+
+ + {activeDropdown === "speed" && ( +
+ {thinkingSpeedOptions.map((s) => ( + + ))} +
+ )} +
+
+ + {activeDropdown === "depth" && ( +
+ {thinkingDepthOptions.map((d) => ( + + ))} +
+ )} +
+ + ) : ( +
+ 视频时长 +
+ {videoDurationOptions.map((opt) => ( + + ))} +
+
+ )} +
+ + +
+ diff --git a/src/features/digital-human/DigitalHumanPage.tsx b/src/features/digital-human/DigitalHumanPage.tsx index dfd1d22..db6062b 100644 --- a/src/features/digital-human/DigitalHumanPage.tsx +++ b/src/features/digital-human/DigitalHumanPage.tsx @@ -17,7 +17,7 @@ import { ThunderboltOutlined, UserOutlined, } 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"; @@ -97,6 +97,7 @@ function DigitalHumanPage({ const activeTaskIdRef = useRef(activeTaskId); activeTaskIdRef.current = activeTaskId; const keepaliveRestoredRef = useRef(false); + const [isDragging, setIsDragging] = useState(false); useEffect(() => { return () => { @@ -148,6 +149,28 @@ function DigitalHumanPage({ setNotice("已取消"); }, [activeTaskId]); + const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); }; + const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); }; + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(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)); + setNotice(`已拖放参考图 ${file.name}`); + } else if (file.type.startsWith("audio/")) { + if (audioPreview) URL.revokeObjectURL(audioPreview); + setAudioName(file.name); + setAudioFile(file); + setAudioPreview(URL.createObjectURL(file)); + setNotice(`已拖放音频 ${file.name}`); + } + }; + const handleDownloadResult = async () => { if (!resultVideoUrl || isDownloadingResult) return; setIsDownloadingResult(true); @@ -419,7 +442,17 @@ function DigitalHumanPage({ +
+ {isDragging ? ( +
+ 释放文件以上传 +
+ ) : null}
参考人像 @@ -492,7 +525,7 @@ function DigitalHumanPage({ {audioPreview ?
- +
} canvas={ resultVideoUrl ? ( @@ -560,7 +593,7 @@ function DigitalHumanPage({
{isCreating && ( + {Math.round(previewZoom * 100)}% + +
{cloneOutput === "video" ? ( @@ -2484,8 +2533,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ) : ( <> {status === "done" ? ( +
- @@ -2493,19 +2543,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{cloneOutput === "set" ? ( clonePreviewCards.map((card) => ( - )) ) : results[0]?.src ? ( - ) : null}
+
) : (
{status === "generating" ? : status === "failed" ? : } @@ -2694,7 +2745,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ) : null} {isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (cloneOutput === "video" ? ( -
+
).isAuthenticated)} productImageDataUrls={ecommerceVideoImageDataUrls} diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index 8d9db81..efc1dfd 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -120,6 +120,7 @@ export default function EcommerceVideoWorkspace({ const [error, setError] = useState(null); const [actionNotice, setActionNotice] = useState(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); + const [flowZoom, setFlowZoom] = useState(1); const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); const actionNoticeTimerRef = useRef(null); @@ -616,6 +617,12 @@ export default function EcommerceVideoWorkspace({ })} +
+ + {Math.round(flowZoom * 100)}% + +
+
{onOpenHistory ? (
{/* ── Delivery dock ────────────────────────────── */} {primaryVideo ? ( diff --git a/src/features/ecommerce/panels/EcommerceClonePanel.tsx b/src/features/ecommerce/panels/EcommerceClonePanel.tsx index 749c171..d38f5dc 100644 --- a/src/features/ecommerce/panels/EcommerceClonePanel.tsx +++ b/src/features/ecommerce/panels/EcommerceClonePanel.tsx @@ -7,6 +7,7 @@ import { ReloadOutlined, SettingOutlined, } from "@ant-design/icons"; +import { createPortal } from "react-dom"; import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react"; import { useRef, useState } from "react"; @@ -118,6 +119,10 @@ interface EcommerceClonePanelProps { setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void; setCloneReferenceMode: (value: CloneReferenceMode) => void; handleCloneReferenceUpload: (event: ChangeEvent) => void; + isCloneReferenceDragging: boolean; + handleCloneReferenceDragOver: (event: DragEvent) => void; + handleCloneReferenceDragLeave: (event: DragEvent) => void; + handleCloneReferenceDrop: (event: DragEvent) => void; setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void; startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void; clearCloneSetCountHold: () => void; @@ -186,6 +191,10 @@ export default function EcommerceClonePanel({ setOpenCloneBasicSelect, setCloneReferenceMode, handleCloneReferenceUpload, + isCloneReferenceDragging, + handleCloneReferenceDragOver, + handleCloneReferenceDragLeave, + handleCloneReferenceDrop, setCloneReplicateLevel, startCloneSetCountHold, clearCloneSetCountHold, @@ -210,6 +219,14 @@ export default function EcommerceClonePanel({ const videoOutfitRefRef = useRef(null); const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState(null); const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState(null); + const [zoomImage, setZoomImage] = useState<{ src: string; x: number; y: number } | null>(null); + + const handleFileMouseEnter = (src: string, event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + setZoomImage({ src, x: rect.left + rect.width / 2, y: rect.top }); + }; + + const handleFileMouseLeave = () => setZoomImage(null); const handleVideoOutfitVideoChange = () => { const file = videoOutfitVideoRef.current?.files?.[0] || null; @@ -383,23 +400,44 @@ export default function EcommerceClonePanel({ {cloneReferenceMode === "upload" ? ( - @@ -754,6 +792,18 @@ export default function EcommerceClonePanel({ ) : null} + {zoomImage + ? createPortal( +
+ +
, + document.body, + ) + : null} ); } diff --git a/src/features/image-workbench/ImageWorkbenchPage.tsx b/src/features/image-workbench/ImageWorkbenchPage.tsx index bcc2087..094bc22 100644 --- a/src/features/image-workbench/ImageWorkbenchPage.tsx +++ b/src/features/image-workbench/ImageWorkbenchPage.tsx @@ -23,7 +23,7 @@ import { TableOutlined, ThunderboltOutlined, } 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 type { WebImageWorkbenchTool, WebViewKey } from "../../types"; @@ -157,6 +157,8 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie const [downloadingResultUrl, setDownloadingResultUrl] = useState(null); const [savingAssetResultUrl, setSavingAssetResultUrl] = useState(null); const [generationError, setGenerationError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [isCameraDragging, setIsCameraDragging] = useState(false); const abortRef = useRef(false); const taskIdRef = useRef(null); const keepaliveRestoredRef = useRef(false); @@ -249,6 +251,37 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie event.target.value = ""; }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith('image/')); + if (!files.length) return; + const selectedFiles = mode === 'blend' ? files : files.slice(0, 1); + selectedFiles.forEach((file) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== 'string') return; + setReferenceImages((current) => (mode === 'blend' ? [...current, reader.result as string] : [reader.result as string])); + setStatus(mode === 'blend' ? `已追加 ${file.name}` : `已导入 ${file.name}`); + }; + reader.readAsDataURL(file); + }); + }; + const handleAddUrl = () => { const nextUrl = imageUrlInput.trim(); if (!nextUrl) return; @@ -281,9 +314,15 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie event.target.value = ""; }; - const handleInpaintDrop = (event: React.DragEvent) => { - event.preventDefault(); - const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("image/")); + const [isInpaintDragging, setIsInpaintDragging] = useState(false); + + const handleInpaintDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); }; + const handleInpaintDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); }; + const handleInpaintDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsInpaintDragging(false); + const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/")); if (!file) return; const reader = new FileReader(); reader.onload = () => { @@ -322,7 +361,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie return; } if (!hasMask) { - setStatus("请先编辑遮罩,涂抹需要重绘的区域"); + setStatus("请先编辑页面,涂抹需要重绘的区域"); return; } if (generating) return; @@ -384,6 +423,33 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie event.target.value = ""; }; + const handleCameraDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsCameraDragging(true); + }; + + const handleCameraDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsCameraDragging(false); + }; + + const handleCameraDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsCameraDragging(false); + const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith('image/')); + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== 'string') return; + setCameraImage(reader.result); + setStatus(`已导入镜头参考图 ${file.name}`); + }; + reader.readAsDataURL(file); + }; + const handleAddCameraUrl = () => { const nextUrl = cameraUrlInput.trim(); if (!nextUrl) return; @@ -715,7 +781,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie accept="image/png,image/jpeg,image/webp" onChange={handleInpaintFileChange} /> -
+
+ {isInpaintDragging ?
释放文件以上传
: null} {renderResultActions(inpaintResultImages[0], 0)} {inpaintResultImages.length > 1 && ( @@ -864,7 +936,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
{!isMaskEditing && ( )} @@ -877,11 +949,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie ) : (
- ) : activeTool === "camera" ? (
@@ -934,7 +978,13 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie accept="image/png,image/jpeg,image/webp" onChange={handleCameraFileChange} /> -
+
+ {isCameraDragging &&
释放文件以上传
}
) : ( -
+
+ {isDragging &&
释放文件以上传
}
+
+

输出

+ 尺寸 +
+ {(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => ( + + ))} +
+
+ 数量 +
+ {([1, 2, 3, 4] as OutputCount[]).map((count) => ( + + ))} +
+
+
+

提示词