diff --git a/screenshots/ecommerce-1024.png b/screenshots/ecommerce-1024.png new file mode 100644 index 0000000..d12143e Binary files /dev/null and b/screenshots/ecommerce-1024.png differ diff --git a/screenshots/ecommerce-1366.png b/screenshots/ecommerce-1366.png new file mode 100644 index 0000000..7c757fe Binary files /dev/null and b/screenshots/ecommerce-1366.png differ diff --git a/screenshots/ecommerce-1440.png b/screenshots/ecommerce-1440.png new file mode 100644 index 0000000..3d9a662 Binary files /dev/null and b/screenshots/ecommerce-1440.png differ diff --git a/screenshots/ecommerce-1920.png b/screenshots/ecommerce-1920.png new file mode 100644 index 0000000..98302fa Binary files /dev/null and b/screenshots/ecommerce-1920.png differ diff --git a/screenshots/ecommerce-hero.png b/screenshots/ecommerce-hero.png new file mode 100644 index 0000000..938d54b Binary files /dev/null and b/screenshots/ecommerce-hero.png differ diff --git a/screenshots/home-features-1024.png b/screenshots/home-features-1024.png new file mode 100644 index 0000000..98c815b Binary files /dev/null and b/screenshots/home-features-1024.png differ diff --git a/screenshots/home-features-1366.png b/screenshots/home-features-1366.png new file mode 100644 index 0000000..e8ded83 Binary files /dev/null and b/screenshots/home-features-1366.png differ diff --git a/screenshots/home-features-1440.png b/screenshots/home-features-1440.png new file mode 100644 index 0000000..77915f3 Binary files /dev/null and b/screenshots/home-features-1440.png differ diff --git a/screenshots/home-features-1920.png b/screenshots/home-features-1920.png new file mode 100644 index 0000000..a87dc13 Binary files /dev/null and b/screenshots/home-features-1920.png differ diff --git a/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/smoke-generation-mocked.mjs b/scripts/smoke-generation-mocked.mjs index f078568..ddf1a75 100644 --- a/scripts/smoke-generation-mocked.mjs +++ b/scripts/smoke-generation-mocked.mjs @@ -42,9 +42,9 @@ assertNoMatch( /dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i, ); assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/); -assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/); +assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/); assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/); -assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/); +assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/); assertMatch( "ecommerce video history must durable-copy media before saving", ecommerceVideoService, 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 0d4a7c3..dcb03c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,5 @@ -import { - BarChartOutlined, - BranchesOutlined, - CustomerServiceOutlined, - DeleteOutlined, - FolderOpenOutlined, - GlobalOutlined, - HeartOutlined, - HomeOutlined, - LayoutOutlined, - RobotOutlined, - ShoppingOutlined, - SwapOutlined, - ToolOutlined, - WalletOutlined, -} from "@ant-design/icons"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import ErrorBoundary from "./components/ErrorBoundary"; import { reportError } from "./utils/errorReporting"; import { initNotificationPermission } from "./utils/generationNotifier"; @@ -36,6 +21,7 @@ import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGene import { translateTaskError } from "./utils/translateTaskError"; import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import AppShell from "./components/AppShell"; +import { ShellIcon } from "./components/ShellIcon"; const NotFoundPage = lazy(() => import("./components/NotFoundPage")); const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; @@ -50,6 +36,7 @@ const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarCons const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); +const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage")); const HomePage = lazy(() => import("./features/home/HomePage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const MorePage = lazy(() => import("./features/more/MorePage")); @@ -60,6 +47,7 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); +const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; @@ -105,6 +93,8 @@ const VIEW_KEYS = new Set([ "assets", "ecommerceHub", "ecommerce", + "ecommerceTemplates", + "sizeTemplate", "scriptTokens", "tokenUsage", "imageWorkbench", @@ -126,6 +116,29 @@ const VIEW_KEYS = new Set([ ]); const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); +const LEGACY_PAGE_STYLE_VIEWS = new Set([ + "login", + "workbench", + "canvas", + "community", + "communityReview", + "communityCaseAdd", + "assets", + "ecommerce", + "ecommerceHub", + "ecommerceTemplates", + "sizeTemplate", + "digitalHuman", + "characterMix", + "more", +]); + +let legacyPageStylesPromise: Promise | null = null; + +function loadLegacyPageStyles(): Promise { + legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css"); + return legacyPageStylesPromise; +} function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -133,6 +146,8 @@ function normalizeViewKey(rawView: string): WebViewKey { ? "login" : rawView === "ecommerceHub" ? "ecommerce" + : rawView === "bug-feedback" || rawView === "feedback" + ? "report" : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement" ? "userAgreement" : rawView === "privacy" || rawView === "privacy-policy" @@ -233,62 +248,124 @@ function App() { const canvasAutoOpenedRecentRef = useRef(false); // Session store - const session = useSessionStore((s) => s.session); - const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen); - const pendingAction = useSessionStore((s) => s.pendingAction); - const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen); - const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage); - const setSession = useSessionStore((s) => s.setSession); - const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt); - const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt); - const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced); - const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced); - const clearSessionState = useSessionStore((s) => s.clearSession); + const { + session, + loginPromptOpen, + pendingAction, + sessionReplacedOpen, + sessionReplacedMessage, + setSession, + openLoginPrompt, + closeLoginPrompt, + showSessionReplaced, + hideSessionReplaced, + clearSession: clearSessionState, + } = useSessionStore(useShallow((s) => ({ + session: s.session, + loginPromptOpen: s.loginPromptOpen, + pendingAction: s.pendingAction, + sessionReplacedOpen: s.sessionReplacedOpen, + sessionReplacedMessage: s.sessionReplacedMessage, + setSession: s.setSession, + openLoginPrompt: s.openLoginPrompt, + closeLoginPrompt: s.closeLoginPrompt, + showSessionReplaced: s.showSessionReplaced, + hideSessionReplaced: s.hideSessionReplaced, + clearSession: s.clearSession, + }))); // Project store - const projects = useProjectStore((s) => s.projects); - const projectsLoaded = useProjectStore((s) => s.projectsLoaded); - const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow); - const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId); - const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject); - const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting); - const setProjects = useProjectStore((s) => s.setProjects); - const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded); - const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow); - const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId); - const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject); - const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject); - const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting); - const clearProjectState = useProjectStore((s) => s.clearProjectState); + const { + projects, + projectsLoaded, + canvasWorkflow, + currentCanvasProjectId, + pendingDeleteProject, + deleteProjectSubmitting, + setProjects, + setProjectsLoaded, + setCanvasWorkflow, + setCurrentCanvasProjectId, + openDeleteProject: openDeleteProjectModal, + closeDeleteProject: closeDeleteProjectModal, + setDeleteProjectSubmitting, + clearProjectState, + } = useProjectStore(useShallow((s) => ({ + projects: s.projects, + projectsLoaded: s.projectsLoaded, + canvasWorkflow: s.canvasWorkflow, + currentCanvasProjectId: s.currentCanvasProjectId, + pendingDeleteProject: s.pendingDeleteProject, + deleteProjectSubmitting: s.deleteProjectSubmitting, + setProjects: s.setProjects, + setProjectsLoaded: s.setProjectsLoaded, + setCanvasWorkflow: s.setCanvasWorkflow, + setCurrentCanvasProjectId: s.setCurrentCanvasProjectId, + openDeleteProject: s.openDeleteProject, + closeDeleteProject: s.closeDeleteProject, + setDeleteProjectSubmitting: s.setDeleteProjectSubmitting, + clearProjectState: s.clearProjectState, + }))); // Task store - const tasks = useTaskStore((s) => s.tasks); - const setTasks = useTaskStore((s) => s.setTasks); - const appendTask = useTaskStore((s) => s.appendTask); - const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks); - const clearTasks = useTaskStore((s) => s.clearTasks); + const { + tasks, + setTasks, + appendTask, + mergeServerTasks, + clearTasks, + } = useTaskStore(useShallow((s) => ({ + tasks: s.tasks, + setTasks: s.setTasks, + appendTask: s.appendTask, + mergeServerTasks: s.mergeServerTasks, + clearTasks: s.clearTasks, + }))); // App store - const usage = useAppStore((s) => s.usage); - const runtimeNotifications = useAppStore((s) => s.runtimeNotifications); - const serverNotifications = useAppStore((s) => s.serverNotifications); - const activeView = useAppStore((s) => s.activeView); - const workspaceExpanded = useAppStore((s) => s.workspaceExpanded); - const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool); - const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate); - const backendHealth = useAppStore((s) => s.backendHealth); - const setUsage = useAppStore((s) => s.setUsage); - const pushNotification = useAppStore((s) => s.pushNotification); - const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications); - const setServerNotifications = useAppStore((s) => s.setServerNotifications); - const setView = useAppStore((s) => s.setView); - const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded); - const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool); - const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate); - const setBackendHealth = useAppStore((s) => s.setBackendHealth); - const markNotificationRead = useAppStore((s) => s.markNotificationRead); - const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead); - const clearAppState = useAppStore((s) => s.clearAppState); + const { + usage, + runtimeNotifications, + serverNotifications, + activeView, + workspaceExpanded, + imageWorkbenchTool, + pendingEcommerceTemplate, + backendHealth, + setUsage, + pushNotification, + setRuntimeNotifications, + setServerNotifications, + setView, + setWorkspaceExpanded, + setImageWorkbenchTool, + setPendingEcommerceTemplate, + setBackendHealth, + markNotificationRead, + markAllNotificationsRead, + clearAppState, + } = useAppStore(useShallow((s) => ({ + usage: s.usage, + runtimeNotifications: s.runtimeNotifications, + serverNotifications: s.serverNotifications, + activeView: s.activeView, + workspaceExpanded: s.workspaceExpanded, + imageWorkbenchTool: s.imageWorkbenchTool, + pendingEcommerceTemplate: s.pendingEcommerceTemplate, + backendHealth: s.backendHealth, + setUsage: s.setUsage, + pushNotification: s.pushNotification, + setRuntimeNotifications: s.setRuntimeNotifications, + setServerNotifications: s.setServerNotifications, + setView: s.setView, + setWorkspaceExpanded: s.setWorkspaceExpanded, + setImageWorkbenchTool: s.setImageWorkbenchTool, + setPendingEcommerceTemplate: s.setPendingEcommerceTemplate, + setBackendHealth: s.setBackendHealth, + markNotificationRead: s.markNotificationRead, + markAllNotificationsRead: s.markAllNotificationsRead, + clearAppState: s.clearAppState, + }))); const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; @@ -296,6 +373,12 @@ function App() { if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); }, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) { + void loadLegacyPageStyles(); + } + }, [activeView, ecommerceEverMounted]); + // Dismiss boot splash after first render useEffect(() => { const splash = document.getElementById("app-boot-splash"); @@ -347,24 +430,24 @@ function App() { const navItems = useMemo( () => [ - { key: "home", label: "首页", hint: "项目入口", icon: }, - { key: "workbench", label: "生成", hint: "对话生成页面", icon: }, + { key: "home", label: "首页", hint: "项目入口", icon: }, + { key: "workbench", label: "生成", hint: "对话生成页面", icon: }, { key: "ecommerce", label: "电商生成", hint: "AI创作与海报生成", - icon: , + icon: , }, - { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: }, - { key: "community", label: "社区", hint: "案例分享与导入", icon: }, - { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: }, - { key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: }, - { key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: }, - { key: "assets", label: "资产库", hint: "角色、场景、道具", icon: }, - { key: "agent", label: "Agent", hint: "拆解与规划", icon: }, - { key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: }, - { key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: }, - { key: "more", label: "工具盒", hint: "图像与镜头工具", icon: }, + { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: }, + { key: "community", label: "社区", hint: "案例分享与导入", icon: }, + { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: }, + { key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: }, + { key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: }, + { key: "assets", label: "资产库", hint: "角色、场景、道具", icon: }, + { key: "agent", label: "Agent", hint: "拆解与规划", icon: }, + { key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: }, + { key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: }, + { key: "more", label: "工具盒", hint: "图像与镜头工具", icon: }, ], [], ); @@ -1090,6 +1173,30 @@ function App() { case "ecommerce": case "ecommerceHub": return null; + case "ecommerceTemplates": + return ( + handleSetView("more")} + onOpenEcommerce={() => handleSetView("ecommerce")} + onSelectTemplate={(template) => { + setPendingEcommerceTemplate(template); + handleSetView("ecommerce"); + }} + onStartCreate={handleStartTemplateCanvasCreate} + onOpenProject={handleOpenProject} + onDeleteProject={handleDeleteProject} + /> + ); + case "sizeTemplate": + return ( + handleSetView("more")} + onOpenEcommerce={() => handleSetView("ecommerce")} + onSelectView={handleSetView} + /> + ); case "digitalHuman": return (
- +

删除项目

确认删除项目「{pendingDeleteProject.name}」?删除后将从服务器项目列表移除。

diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index accb697..5e78c26 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -3,6 +3,7 @@ import { buildAuthHeaders, isRecord, readJsonResponse, + serverRequest, throwResponseError, } from "./serverConnection"; import { isOptionalApiRouteMissing } from "./apiErrorUtils"; @@ -243,6 +244,10 @@ function emitImageRouteDebug(label: string, payload: Record): v let taskHistoryRouteMissing = false; +const TASK_SUBMIT_TIMEOUT_MS = 90_000; +const TASK_STATUS_TIMEOUT_MS = 20_000; +const NON_RETRYING_REQUEST = { maxRetries: 0 }; + export const aiGenerationClient = { async createImageTask(input: ImageGenInput): Promise { const requestUrl = buildApiUrl("ai/image"); @@ -256,15 +261,13 @@ export const aiGenerationClient = { projectId: input.projectId, conversationId: input.conversationId, }); - const res = await fetch(requestUrl, { + const payload = await serverRequest("ai/image", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image generation request failed", }); - if (!res.ok) { - await throwResponseError(res, "Image generation request failed"); - } - const payload = await readJsonResponse(res, "Image generation response failed"); if (payload.providerDebug) { emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); } @@ -272,96 +275,83 @@ export const aiGenerationClient = { }, async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/video"), { + return serverRequest<{ taskId: string }>("ai/video", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video generation request failed", }); - if (!res.ok) { - await throwResponseError(res, "Video generation request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Video generation response failed"); }, async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/video/super-resolve"), { + return serverRequest<{ taskId: string }>("ai/video/super-resolve", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video super-resolution request failed", }); - if (!res.ok) { - await throwResponseError(res, "Video super-resolution request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed"); }, async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), { + return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Subtitle removal request failed", }); - if (!res.ok) { - await throwResponseError(res, "Subtitle removal request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed"); }, async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/video/edit"), { + return serverRequest<{ taskId: string }>("ai/video/edit", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }), + body: { ...input, model: input.model || "happyhorse-1.0-video-edit" }, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video edit request failed", }); - if (!res.ok) { - await throwResponseError(res, "Video edit request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Video edit response failed"); }, async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/image/super-resolve"), { + return serverRequest<{ taskId: string }>("ai/image/super-resolve", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image super-resolution request failed", }); - if (!res.ok) { - await throwResponseError(res, "Image super-resolution request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed"); }, async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { - const res = await fetch(buildApiUrl("ai/image/edit"), { + return serverRequest<{ taskId: string }>("ai/image/edit", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image edit request failed", }); - if (!res.ok) { - await throwResponseError(res, "Image edit request failed"); - } - return readJsonResponse<{ taskId: string }>(res, "Image edit response failed"); }, async cancelTask(taskId: string): Promise { - const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), { - method: "PATCH", - headers: buildAuthHeaders(), - }); - if (!res.ok && res.status !== 404) { - await throwResponseError(res, "Task cancel failed"); + try { + await serverRequest(`ai/tasks/${taskId}/cancel`, { + method: "PATCH", + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Task cancel failed", + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) return; + throw error; } }, async getTaskStatus(taskId: string): Promise { - const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), { - method: "GET", - headers: buildAuthHeaders(), + return serverRequest(`ai/tasks/${taskId}`, { + timeoutMs: TASK_STATUS_TIMEOUT_MS, + fallbackMessage: "Task status request failed", }); - if (!res.ok) { - await throwResponseError(res, "Task status request failed"); - } - return readJsonResponse(res, "Task status response failed"); }, async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> { @@ -387,49 +377,41 @@ export const aiGenerationClient = { if (params?.status) search.set("status", params.status); if (params?.type) search.set("type", params.type); if (params?.projectId) search.set("projectId", params.projectId); - const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), { - method: "GET", - headers: buildAuthHeaders(), - }); - if (!res.ok) { - try { - await throwResponseError(res, "Task history request failed"); - } catch (error) { - if (isOptionalApiRouteMissing(error)) { - taskHistoryRouteMissing = true; - return []; - } - throw error; + try { + const payload = await serverRequest(`ai/tasks${search.toString() ? `?${search}` : ""}`, { + fallbackMessage: "Task history request failed", + }); + return extractTaskList(payload).map(toPreviewTask); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + taskHistoryRouteMissing = true; + return []; } + throw error; } - const payload = await readJsonResponse(res, "Task history response failed"); - return extractTaskList(payload).map(toPreviewTask); }, async bindTaskToConversation(taskId: string, conversationId: number): Promise { - const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), { - method: "PATCH", - headers: buildAuthHeaders(), - body: JSON.stringify({ conversationId }), - }); - if (res.status === 404) { - return; - } - if (!res.ok) { - await throwResponseError(res, "Task conversation binding failed"); + try { + await serverRequest(`ai/tasks/${taskId}/conversation`, { + method: "PATCH", + body: { conversationId }, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Task conversation binding failed", + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) return; + throw error; } }, async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { - const res = await fetch(buildApiUrl("oss/upload"), { + return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Asset upload failed", }); - if (!res.ok) { - await throwResponseError(res, "Asset upload failed"); - } - return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed"); }, async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { @@ -451,15 +433,12 @@ export const aiGenerationClient = { }, async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { - const res = await fetch(buildApiUrl("oss/upload-by-url"), { + return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify(input), + body: input, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Asset upload by URL failed", }); - if (!res.ok) { - await throwResponseError(res, "Asset upload by URL failed"); - } - return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed"); }, subscribeTaskStatus( diff --git a/src/api/assetClient.ts b/src/api/assetClient.ts index 049fd1f..954835e 100644 --- a/src/api/assetClient.ts +++ b/src/api/assetClient.ts @@ -67,7 +67,13 @@ function normalizeAssetStatus(value: unknown): WebAssetItem["status"] { } function normalizeTags(value: unknown): string[] { - return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : []; + if (!Array.isArray(value)) return []; + const tags: string[] = []; + for (const item of value) { + const tag = toStringValue(item); + if (tag) tags.push(tag); + } + return tags; } function normalizeAsset(raw: unknown): ServerAssetItem { diff --git a/src/api/communityClient.ts b/src/api/communityClient.ts index 0000bf2..706fb2a 100644 --- a/src/api/communityClient.ts +++ b/src/api/communityClient.ts @@ -62,9 +62,13 @@ function toStringValue(value: unknown, fallback = ""): string { } function toStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.map((item) => toStringValue(item)).filter(Boolean) - : []; + if (!Array.isArray(value)) return []; + const result: string[] = []; + for (const item of value) { + const text = toStringValue(item); + if (text) result.push(text); + } + return result; } function toMetadata(value: unknown): Record { diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index b3ef7a0..c7fd90d 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -376,13 +376,18 @@ function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string { function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] { if (!Array.isArray(value)) return undefined; - return value.filter(isRecord).map((entry) => ({ - name: toStringValue(entry.name, "Preview package"), - expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), - remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), - remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), - remainingText: toNumber(entry.remainingText ?? entry.remaining_text), - })); + const packages: NonNullable = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + packages.push({ + name: toStringValue(entry.name, "Preview package"), + expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), + remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), + remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), + remainingText: toNumber(entry.remainingText ?? entry.remaining_text), + }); + } + return packages; } function normalizeUser(raw: unknown): WebUserSession["user"] | null { diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts index 33ce1f4..7d284d0 100644 --- a/src/api/modelCapabilitiesClient.ts +++ b/src/api/modelCapabilitiesClient.ts @@ -49,9 +49,13 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null { } function normalizeModelList(value: unknown): ModelCapabilityOption[] { - return Array.isArray(value) - ? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item)) - : []; + if (!Array.isArray(value)) return []; + const options: ModelCapabilityOption[] = []; + for (const item of value) { + const option = normalizeModelOption(item); + if (option) options.push(option); + } + return options; } function createFallbackCapabilities(): WebModelCapabilities { diff --git a/src/api/projectTaskClient.ts b/src/api/projectTaskClient.ts index efe56e3..65c96aa 100644 --- a/src/api/projectTaskClient.ts +++ b/src/api/projectTaskClient.ts @@ -71,10 +71,19 @@ function normalizeTask(raw: unknown): ServerProjectTask | null { } function extractTasks(payload: unknown): ServerProjectTask[] { - if (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[]; + const normalizeTasks = (rows: unknown[]): ServerProjectTask[] => { + const tasks: ServerProjectTask[] = []; + for (const row of rows) { + const task = normalizeTask(row); + if (task) tasks.push(task); + } + return tasks; + }; + + if (Array.isArray(payload)) return normalizeTasks(payload); if (!isRecord(payload)) return []; const rows = payload.tasks ?? payload.items; - return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : []; + return Array.isArray(rows) ? normalizeTasks(rows) : []; } function taskTitle(task: ServerProjectTask): string { @@ -110,8 +119,12 @@ export const projectTaskClient = { }, async listForProjects(projectIds: string[]): Promise { - const uniqueIds = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean))); - const results = await Promise.all(uniqueIds.map((id) => listProjectTasks(id))); + const uniqueIds = new Set(); + for (const projectId of projectIds) { + const id = projectId.trim(); + if (id) uniqueIds.add(id); + } + const results = await Promise.all(Array.from(uniqueIds, (id) => listProjectTasks(id))); return results.flat(); }, diff --git a/src/api/providerHealthClient.ts b/src/api/providerHealthClient.ts index 11c48b4..6552661 100644 --- a/src/api/providerHealthClient.ts +++ b/src/api/providerHealthClient.ts @@ -1,4 +1,4 @@ -import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; +import { serverRequest } from "./serverConnection"; export interface ProviderHealthEntry { status: string; @@ -32,13 +32,8 @@ export interface ProviderHealthResponse { export const providerHealthClient = { async getStatus(): Promise { - const res = await fetch(buildApiUrl("admin/providers/status"), { - method: "GET", - headers: buildAuthHeaders(), + return serverRequest("admin/providers/status", { + fallbackMessage: "Provider health request failed", }); - if (!res.ok) { - throw new Error(`Provider health request failed (${res.status})`); - } - return res.json() as Promise; }, -}; \ No newline at end of file +}; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index d593541..bc11345 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -1,4 +1,4 @@ -import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; +import { serverRequest } from "./serverConnection"; export interface ScriptEvalResult { totalScore: number; @@ -107,6 +107,17 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function normalizeEvidenceItems(source: unknown[], limit: number): string[] { + const items: string[] = []; + for (const item of source) { + const value = String(item).trim(); + if (!value) continue; + items.push(value); + if (items.length >= limit) break; + } + return items; +} + function normalizeNestedScores(value: unknown): Record> { if (!isRecord(value)) return {}; @@ -132,7 +143,7 @@ function normalizeEvidence(value: unknown): Record { const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); if (!Array.isArray(source)) continue; - const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3); + const items = normalizeEvidenceItems(source, 3); if (items.length > 0) normalized[dimensionKey] = items; } @@ -140,10 +151,13 @@ function normalizeEvidence(value: unknown): Record { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - const res = await fetch(buildApiUrl("ai/chat"), { + const payload = await serverRequest<{ + content?: string; + choices?: Array<{ message?: { content?: string } }>; + text?: string; + }>("ai/chat", { method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ + body: { model: MODEL, messages: [ { role: "system", content: EVAL_SYSTEM_PROMPT }, @@ -153,16 +167,13 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom stream: false, temperature: 0.3, max_tokens: 4096, - }), + }, signal, + timeoutMs: 180_000, + maxRetries: 0, + fallbackMessage: "评测请求失败", }); - if (!res.ok) { - const errText = await res.text().catch(() => ""); - throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`); - } - - const payload = await res.json(); const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; if (!content) throw new Error("模型未返回有效内容"); diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index d1302fa..3c2a499 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -22,6 +22,9 @@ export interface ServerRequestOptions { signal?: AbortSignal; /** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */ timeoutMs?: number; + /** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */ + maxRetries?: number; + fallbackMessage?: string; } export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -343,8 +346,10 @@ const MAX_RETRIES = 2; export async function serverRequest(path: string, options?: ServerRequestOptions): Promise { let lastError: unknown; const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = options?.maxRetries ?? MAX_RETRIES; + const fallbackMessage = options?.fallbackMessage || "Request failed"; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { const controller = timeoutMs > 0 ? new AbortController() : null; const timeoutId = controller && typeof window !== "undefined" @@ -366,11 +371,11 @@ export async function serverRequest(path: string, options?: ServerRequestOpti credentials: "include", }); - const payload = await readJsonResponse(response, "Request failed"); + const payload = await readJsonResponse(response, fallbackMessage); return (options?.raw ? payload : unwrapApiPayload(payload)) as T; } catch (error) { lastError = error; - if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) { + if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) { await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error))); continue; } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 3f05f94..bc32b75 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,15 +1,3 @@ -import { - ArrowDownOutlined, - ArrowUpOutlined, - CheckCircleOutlined, - FlagOutlined, - InfoCircleOutlined, - LoginOutlined, - LogoutOutlined, - PlusCircleOutlined, - UserOutlined, - WalletOutlined, -} from "@ant-design/icons"; import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; @@ -20,10 +8,12 @@ import { canManageCommunityCases, canReviewCommunity } from "../features/communi import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; import NotificationCenter from "./NotificationCenter"; import BetaApplicationModal from "./BetaApplicationModal"; -import { RechargeModal } from "./RechargeModal/RechargeModal"; 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"; interface AppShellProps { activeView: WebViewKey; @@ -42,6 +32,32 @@ interface AppShellProps { } const BRAND_LOGO_URL = ossAssets.brand.logo; +const TOOL_SURFACE_VIEW_SET = new Set([ + "workbench", + "canvas", + "more", + "scriptTokens", + "tokenUsage", + "ecommerceTemplates", + "sizeTemplate", + "imageWorkbench", + "resolutionUpscale", + "digitalHuman", + "dialogGenerator", + "avatarConsole", + "characterMix", +] as WebViewKey[]); +const PRIMARY_NAV_ORDER: WebViewKey[] = [ + "workbench", + "ecommerce", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "more", + "assets", + "community", +]; function formatBalance(cents: number): string { const value = Math.max(0, cents) / 100; @@ -68,6 +84,7 @@ function AppShell({ const submenuHideTimerRef = useRef(null); const [profileOpen, setProfileOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false); + const [RechargeModal, setRechargeModal] = useState(null); const [infoOpen, setInfoOpen] = useState(false); const [betaOpen, setBetaOpen] = useState(false); const infoRef = useRef(null); @@ -78,38 +95,13 @@ function AppShell({ const isAuthView = activeView === "login"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; - const toolSurfaceViews = [ - "workbench", - "canvas", - "more", - "scriptTokens", - "tokenUsage", - "ecommerceTemplates", - "sizeTemplate", - "imageWorkbench", - "resolutionUpscale", - "digitalHuman", - "dialogGenerator", - "avatarConsole", - "characterMix", - ] as WebViewKey[]; - const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView); + const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView); const visibleNavItems = useMemo( () => { - const orderedKeys: WebViewKey[] = [ - "workbench", - "ecommerce", - "sizeTemplate", - "canvas", - "scriptTokens", - "tokenUsage", - "more", - "assets", - "community", - ]; - return orderedKeys - .map((key) => navItems.find((item) => item.key === key)) + const navItemByKey = new Map(navItems.map((item) => [item.key, item])); + return PRIMARY_NAV_ORDER + .map((key) => navItemByKey.get(key)) .filter((item): item is WebNavItem => Boolean(item)); }, [navItems], @@ -129,6 +121,7 @@ function AppShell({ return; } + void loadDarkGreenTheme(); document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.uiTheme = "dark-green"; document.documentElement.style.colorScheme = "dark"; @@ -193,6 +186,21 @@ function AppShell({ }; }, []); + useEffect(() => { + if (!rechargeOpen || RechargeModal) return; + + let cancelled = false; + void loadRechargeModal().then((component) => { + if (!cancelled) { + setRechargeModal(() => component); + } + }); + + return () => { + cancelled = true; + }; + }, [RechargeModal, rechargeOpen]); + const showSubmenu = (key: WebViewKey) => { if (submenuHideTimerRef.current) { window.clearTimeout(submenuHideTimerRef.current); @@ -313,7 +321,7 @@ function AppShell({ aria-label="返回页面顶部" onClick={() => scrollActivePage("top")} > - + ) : null} @@ -361,7 +369,7 @@ function AppShell({ aria-label="网站信息" onClick={() => setInfoOpen((c) => !c)} > - +
@@ -373,6 +381,7 @@ function AppShell({
{publicConfig.contactPhone || "由服务器配置"}
@@ -384,7 +393,7 @@ function AppShell({ aria-label={`积分余额 ${displayedBalanceLabel}`} onClick={() => toast.info("充值功能即将开放,敬请期待")} > - + {displayedBalanceLabel}
@@ -408,7 +417,7 @@ function AppShell({ ) : ( <> - + 登录 / 注册 )} @@ -436,7 +445,7 @@ function AppShell({
{session?.source === "server" ? "服务器会话" : "预览会话"}
@@ -448,7 +457,7 @@ function AppShell({ onSelectView("login"); }} > - + 个人中心 {showCommunityReview ? ( <> @@ -472,7 +481,7 @@ function AppShell({ onSelectView("communityReview"); }} > - + 社区审核 @@ -487,7 +496,7 @@ function AppShell({ onSelectView("communityCaseAdd"); }} > - + 添加案例 @@ -501,9 +510,10 @@ function AppShell({
{session?.user.role === "admin" ? : null} - setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> + {rechargeOpen && RechargeModal ? ( + setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> + ) : null} setBetaOpen(false)} /> - ); } diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index 2657238..1f67860 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -1,4 +1,5 @@ import { useCallback, useRef, useState, type ReactNode } from "react"; +import "../styles/components/dropzone.css"; interface DropZoneProps { accept?: string; diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 7610ceb..a67f0fc 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "../styles/components/empty-state.css"; interface EmptyStateProps { icon?: ReactNode; diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx index 2a09a9c..0d57245 100644 --- a/src/components/NotFoundPage.tsx +++ b/src/components/NotFoundPage.tsx @@ -1,5 +1,6 @@ import { HomeOutlined } from "@ant-design/icons"; import { useCallback } from "react"; +import "../styles/pages/not-found.css"; interface NotFoundPageProps { onGoHome: () => void; diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index 586b9f2..a0a3757 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -1,26 +1,17 @@ -import { - BellOutlined, - CheckCircleOutlined, - CloseCircleOutlined, - DeleteOutlined, - DislikeOutlined, - ExclamationCircleOutlined, - LikeOutlined, - LockOutlined, -} from "@ant-design/icons"; import { useEffect, useRef, useState } from "react"; import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; import { AnimatedPanel } from "./AnimatedPanel"; +import { ShellIcon } from "./ShellIcon"; const NOTIFICATION_ICONS: Record = { - task_completed: , - task_failed: , - review_pending: , - review_passed: , - review_rejected: , - credits_low: , - session_expired: , - info: , + task_completed: , + task_failed: , + review_pending: , + review_passed: , + review_rejected: , + credits_low: , + session_expired: , + info: , }; function parseTimestamp(dateStr: string): number { @@ -111,7 +102,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl aria-label={`通知中心${unreadCount > 0 ? `,${unreadCount}条未读` : ""}`} onClick={() => { setOpen((v) => !v); setNow(Date.now()); }} > - + {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} @@ -127,7 +118,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl )} {notifications.length > 0 && onClear && ( )} @@ -135,7 +126,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
{notifications.length === 0 ? (
- + 暂无通知
) : ( diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx index 95be2eb..49e01da 100644 --- a/src/components/RechargeModal/RechargeModal.tsx +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -1,5 +1,6 @@ import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { useMemo, useState, type ReactNode } from "react"; +import "../../styles/components/recharge-modal.css"; import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient"; import { toast } from "../toast/toastStore"; @@ -116,7 +117,7 @@ const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" }, ]; -interface RechargeModalProps { +export interface RechargeModalProps { open: boolean; onClose: () => void; currentBalance?: number; diff --git a/src/components/RechargeModal/loadRechargeModal.ts b/src/components/RechargeModal/loadRechargeModal.ts new file mode 100644 index 0000000..1d14aff --- /dev/null +++ b/src/components/RechargeModal/loadRechargeModal.ts @@ -0,0 +1,14 @@ +import type { ComponentType } from "react"; +import type { RechargeModalProps } from "./RechargeModal"; + +export type RechargeModalComponent = ComponentType; + +let rechargeModalPromise: Promise | null = null; + +export function loadRechargeModal(): Promise { + if (!rechargeModalPromise) { + rechargeModalPromise = import("./RechargeModal").then((module) => module.RechargeModal); + } + + return rechargeModalPromise; +} diff --git a/src/components/ShellIcon.tsx b/src/components/ShellIcon.tsx new file mode 100644 index 0000000..03ff357 --- /dev/null +++ b/src/components/ShellIcon.tsx @@ -0,0 +1,344 @@ +import type { CSSProperties } from "react"; + +export type ShellIconName = + | "arrow-down" + | "arrow-left" + | "arrow-up" + | "bar-chart" + | "bell" + | "branches" + | "check-circle" + | "chevron-left" + | "chevron-right" + | "close-circle" + | "copy" + | "customer-service" + | "delete" + | "dislike" + | "download" + | "exclamation-circle" + | "flag" + | "file-text" + | "folder" + | "global" + | "heart" + | "home" + | "info-circle" + | "like" + | "line-chart" + | "lock" + | "login" + | "logout" + | "loading" + | "plus-circle" + | "reload" + | "robot" + | "shopping" + | "swap" + | "team" + | "thunderbolt" + | "tool" + | "upload" + | "user" + | "wallet" + | "warning"; + +interface ShellIconProps { + name: ShellIconName; + className?: string; + style?: CSSProperties; +} + +function renderIcon(name: ShellIconName) { + switch (name) { + case "arrow-down": + return ; + case "arrow-left": + return ; + case "arrow-up": + return ; + case "bar-chart": + return ( + <> + + + + + + + ); + case "bell": + return ( + <> + + + + ); + case "branches": + return ( + <> + + + + + + + ); + case "check-circle": + return ( + <> + + + + ); + case "chevron-left": + return ; + case "chevron-right": + return ; + case "close-circle": + return ( + <> + + + + ); + case "copy": + return ( + <> + + + + ); + case "customer-service": + return ( + <> + + + + + + ); + case "delete": + return ( + <> + + + + + + + ); + case "download": + return ( + <> + + + + + ); + case "dislike": + return ( + <> + + + + + ); + case "exclamation-circle": + return ( + <> + + + + + ); + case "flag": + return ( + <> + + + + ); + case "file-text": + return ( + <> + + + + + + ); + case "folder": + return ; + case "global": + return ( + <> + + + + + + ); + case "heart": + return ; + case "home": + return ( + <> + + + + + ); + case "info-circle": + return ( + <> + + + + + ); + case "like": + return ( + <> + + + + + ); + case "line-chart": + return ( + <> + + + + + ); + case "lock": + return ( + <> + + + + ); + case "login": + return ( + <> + + + + + ); + case "logout": + return ( + <> + + + + + ); + case "loading": + return ( + <> + + + + ); + case "plus-circle": + return ( + <> + + + + + ); + case "reload": + return ( + <> + + + + ); + case "robot": + return ( + <> + + + + + + + ); + case "shopping": + return ( + <> + + + + + + ); + case "swap": + return ( + <> + + + + ); + case "team": + return ( + <> + + + + + + ); + case "thunderbolt": + return ; + case "tool": + return ; + case "upload": + return ( + <> + + + + + ); + case "user": + return ( + <> + + + + ); + case "wallet": + return ( + <> + + + + + ); + case "warning": + return ( + <> + + + + + ); + default: + return ; + } +} + +export function ShellIcon({ name, className, style }: ShellIconProps) { + return ( + + ); +} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index 491a942..5c6f6ca 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -1,4 +1,5 @@ import type { CSSProperties } from "react"; +import "../styles/components/skeleton.css"; interface SkeletonProps { width?: string | number; diff --git a/src/components/StudioToolLayout.tsx b/src/components/StudioToolLayout.tsx index a95e1aa..4b9f59f 100644 --- a/src/components/StudioToolLayout.tsx +++ b/src/components/StudioToolLayout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "../styles/pages/studio-layout.css"; interface StudioToolLayoutProps { toolstrip?: ReactNode; diff --git a/src/features/agent/AgentPage.tsx b/src/features/agent/AgentPage.tsx index fff8b76..21ce50d 100644 --- a/src/features/agent/AgentPage.tsx +++ b/src/features/agent/AgentPage.tsx @@ -14,6 +14,7 @@ import { ThunderboltOutlined, } from "@ant-design/icons"; import { useEffect, useRef, useState } from "react"; +import "../../styles/pages/agent.css"; import WorkspacePageShell from "../../components/WorkspacePageShell"; import type { WebGenerationPreviewTask } from "../../types"; diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index e3cf85c..51b98a3 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -11,6 +11,7 @@ import { UserOutlined, } from "@ant-design/icons"; import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react"; +import "../../styles/pages/assets.css"; import { assetClient, type ServerAssetItem } from "../../api/assetClient"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { useDebounce } from "../../hooks/useDebounce"; diff --git a/src/features/canvas/CanvasMarkingPopover.tsx b/src/features/canvas/CanvasMarkingPopover.tsx new file mode 100644 index 0000000..2c482af --- /dev/null +++ b/src/features/canvas/CanvasMarkingPopover.tsx @@ -0,0 +1,40 @@ +interface CanvasMarkingPopoverProps { + value?: string; + placeholder: string; + onChange: (value: string) => void; + onClear: () => void; + onDone: () => void; +} + +export function CanvasMarkingPopover({ + value, + placeholder, + onChange, + onClear, + onDone, +}: CanvasMarkingPopoverProps) { + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +